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,363 @@
"""DB 기반 하이브리드 시뮬레이션 (신호=10분봉 / 스탑=1분봉).
실행 흐름:
1. pyupbit에서 10분봉(300봉) + 1분봉(2880봉) fetch → BACKTEST_OHLCV upsert
2. BACKTEST_OHLCV에서 데이터 로드
3. 하이브리드 시뮬 실행
- 10분봉 타임인덱스로 신호 감지 / 진입
- 각 10분봉 구간 내 1분봉으로 트레일링스탑 체크
4. 결과 출력
"""
import sys
import os
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 time
import pyupbit
import pandas as pd
import numpy as np
import oracledb
# ── Oracle 연결 ─────────────────────────────────────────────────────────────
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)
# ── 전략 파라미터 ────────────────────────────────────────────────────────────
LOCAL_VOL_N = 28
QUIET_N = 12
QUIET_PCT = 2.0
THRESH = 4.8
SIGNAL_TO = 48
VOL_THRESH = 6.0
FNG = 14
ATR_N = 28
ATR_MULT = 1.5
ATR_MIN = 0.010
ATR_MAX = 0.020
TS_N = 48
TIME_STOP_PCT = 3.0
BUDGET = 15_000_000
MAX_POS = 3
FEE = 0.0005
PER_POS = BUDGET // MAX_POS
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',
]
SIM_START = '2026-03-03 00:00:00'
SIM_END = '2026-03-04 13:30:00'
# ── BACKTEST_OHLCV 공통 함수 ─────────────────────────────────────────────────
def upsert_ohlcv(conn, ticker: str, interval_cd: str, df: pd.DataFrame) -> int:
"""DataFrame을 BACKTEST_OHLCV에 저장 (기존 TS 스킵). 반환: 신규 삽입 건수."""
cur = conn.cursor()
min_ts = df.index.min().to_pydatetime()
cur.execute(
"SELECT ts FROM backtest_ohlcv WHERE ticker=:t AND interval_cd=:iv AND ts >= :since",
{"t": ticker, "iv": interval_cd, "since": min_ts}
)
existing = {r[0] for r in cur.fetchall()}
new_rows = [
(ticker, interval_cd, ts_idx.to_pydatetime(),
float(row["open"]), float(row["high"]), float(row["low"]),
float(row["close"]), float(row["volume"]))
for ts_idx, row in df.iterrows()
if ts_idx.to_pydatetime() not in existing
]
if not new_rows:
return 0
cur.executemany(
"INSERT INTO backtest_ohlcv (ticker, interval_cd, ts, open_p, high_p, low_p, close_p, volume_p) "
"VALUES (:1, :2, :3, :4, :5, :6, :7, :8)",
new_rows
)
conn.commit()
return len(new_rows)
def load_from_db(conn, ticker: str, interval_cd: str, since: str):
"""BACKTEST_OHLCV에서 ticker의 OHLCV 로드."""
cur = conn.cursor()
cur.execute(
"SELECT ts, open_p, high_p, low_p, close_p, volume_p "
"FROM backtest_ohlcv "
"WHERE ticker=:ticker AND interval_cd=:iv "
"AND ts >= TO_TIMESTAMP(:since, 'YYYY-MM-DD HH24:MI:SS') "
"ORDER BY ts",
{"ticker": ticker, "iv": interval_cd, "since": since}
)
rows = cur.fetchall()
if not rows:
return None
df = pd.DataFrame(rows, columns=["ts","open","high","low","close","volume"])
df["ts"] = pd.to_datetime(df["ts"])
df.set_index("ts", inplace=True)
return df.astype(float)
# ── 1단계: DB 업데이트 ───────────────────────────────────────────────────────
def refresh_db(conn) -> None:
"""10분봉(300봉) + 1분봉(2880봉) fetch → BACKTEST_OHLCV upsert."""
print("BACKTEST_OHLCV 업데이트 중...", flush=True)
total10, total1 = 0, 0
for tk in TICKERS:
try:
# 10분봉
df10 = pyupbit.get_ohlcv(tk, interval='minute10', count=300)
if df10 is not None and len(df10) >= 10:
total10 += upsert_ohlcv(conn, tk, 'minute10', df10)
time.sleep(0.12)
# 1분봉 (2일치 ≈ 2880봉, 내부 자동 페이지네이션)
df1 = pyupbit.get_ohlcv(tk, interval='minute1', count=2880)
if df1 is not None and len(df1) >= 60:
total1 += upsert_ohlcv(conn, tk, 'minute1', df1)
time.sleep(0.15)
print(f" {tk}: 10m={len(df10) if df10 is not None else 0}봉 / "
f"1m={len(df1) if df1 is not None else 0}", flush=True)
except Exception as e:
print(f" {tk} 오류: {e}")
print(f"DB 업데이트 완료: 10분봉 신규 {total10}행 / 1분봉 신규 {total1}\n", flush=True)
# ── ATR 계산 ────────────────────────────────────────────────────────────────
def calc_atr(df, i, n=28):
start = max(0, i - n)
sub = df.iloc[start:i]
if len(sub) < 5:
return ATR_MIN
hi = sub['high'].values
lo = sub['low'].values
cl = sub['close'].values
tr = [max(hi[j]-lo[j], abs(hi[j]-cl[j-1]) if j>0 else 0,
abs(lo[j]-cl[j-1]) if j>0 else 0) for j in range(len(sub))]
atr_pct = np.mean(tr) / cl[-1] if cl[-1] > 0 else ATR_MIN
return max(ATR_MIN, min(ATR_MAX, atr_pct * ATR_MULT))
# ── 하이브리드 시뮬 ──────────────────────────────────────────────────────────
def run_sim(raw10: dict, raw1: dict) -> None:
"""신호=10분봉 / 스탑=1분봉 하이브리드 시뮬."""
# 10분봉 공통 타임인덱스
all_idx10 = None
for df in raw10.values():
all_idx10 = df.index if all_idx10 is None else all_idx10.union(df.index)
all_idx10 = all_idx10.sort_values()
mask = (all_idx10 >= SIM_START) & (all_idx10 <= SIM_END)
sim_idx10 = all_idx10[mask]
if len(sim_idx10) == 0:
print("시뮬 구간 데이터 없음")
return
print(f"시뮬 구간(10분봉): {sim_idx10[0]} ~ {sim_idx10[-1]} ({len(sim_idx10)}봉)")
# 1분봉 커버리지 확인
n1m = sum(
len(df[(df.index >= SIM_START) & (df.index <= SIM_END)])
for df in raw1.values() if len(raw1) > 0
)
print(f"1분봉 총 {n1m}봉 (스탑 체크용)\n")
positions = {} # ticker → {buy_price, peak, entry_i, invested, atr_stop}
signals = {} # ticker → {price, vol_r, sig_i}
trades = []
for i, ts10 in enumerate(sim_idx10):
ts10_prev = sim_idx10[i - 1] if i > 0 else ts10
# ── 1) 1분봉으로 스탑 체크 ────────────────────────────────────────
for tk in list(positions.keys()):
pos = positions[tk]
# 진입 캔들 종료 전엔 체크 금지
# 10분봉 ts=X 는 [X, X+10min) 구간이므로 실제 진입은 X+9:59
# → X+10min 이후 1분봉부터 체크
entry_candle_end = sim_idx10[pos['entry_i']] + pd.Timedelta(minutes=10)
if tk not in raw1:
# 1분봉 없으면 10분봉 종가로 fallback (단, 진입 다음 봉부터)
if ts10 <= sim_idx10[pos['entry_i']]:
continue
if tk not in raw10 or ts10 not in raw10[tk].index:
continue
current = float(raw10[tk].loc[ts10, 'close'])
_check_stop(pos, tk, current, ts10, i, sim_idx10, trades, positions)
continue
df1 = raw1[tk]
# 진입 캔들 종료(entry_candle_end) 이후 + 현재 10분봉 구간 이내
mask1 = (df1.index >= entry_candle_end) & (df1.index > ts10_prev) & (df1.index <= ts10)
sub1 = df1[mask1]
for ts1m, row1m in sub1.iterrows():
current = float(row1m['close'])
if _check_stop(pos, tk, current, ts1m, i, sim_idx10, trades, positions):
break # 이미 청산됨
# ── 2) 신호 만료 ────────────────────────────────────────────────
for tk in list(signals.keys()):
if i - signals[tk]['sig_i'] > SIGNAL_TO:
del signals[tk]
# ── 3) 신호 감지 + 진입 (10분봉 기준) ──────────────────────────
for tk in TICKERS:
if tk in positions: continue
if len(positions) >= MAX_POS: break
if tk not in raw10: continue
df10 = raw10[tk]
if ts10 not in df10.index: continue
loc = df10.index.get_loc(ts10)
if loc < LOCAL_VOL_N + QUIET_N + 2: continue
vol_prev = float(df10['volume'].iloc[loc - 1])
vol_avg = float(df10['volume'].iloc[loc - LOCAL_VOL_N - 1:loc - 1].mean())
vol_r = vol_prev / vol_avg if vol_avg > 0 else 0.0
current = float(df10['close'].iloc[loc])
close_qn = float(df10['close'].iloc[loc - QUIET_N])
chg = abs(current - close_qn) / close_qn * 100 if close_qn > 0 else 999.0
if vol_r >= VOL_THRESH and chg < QUIET_PCT:
if tk not in signals or vol_r > signals[tk]['vol_r']:
signals[tk] = {'price': current, 'vol_r': vol_r, 'sig_i': i}
if tk in signals:
sig_p = signals[tk]['price']
move = (current - sig_p) / sig_p * 100
if move >= THRESH:
atr_stop = calc_atr(df10, loc, ATR_N)
positions[tk] = {
'buy_price': current, 'peak': current,
'entry_i': i, 'invested': PER_POS,
'atr_stop': atr_stop,
}
del signals[tk]
# ── 미청산 강제 청산 ─────────────────────────────────────────────────────
last_ts10 = sim_idx10[-1]
for tk, pos in list(positions.items()):
if tk not in raw10: continue
df10 = raw10[tk]
current = float(df10.loc[last_ts10, 'close']) if last_ts10 in df10.index else pos['buy_price']
pnl = (current - pos['buy_price']) / pos['buy_price'] * 100
fee = pos['invested'] * FEE + current * (pos['invested']/pos['buy_price']) * FEE
krw = current * (pos['invested']/pos['buy_price']) - pos['invested'] - fee
trades.append({
'date': last_ts10.date(), 'ticker': tk,
'buy_price': pos['buy_price'], 'sell_price': current,
'pnl_pct': pnl, 'krw': krw, 'reason': '미청산(현재가)',
'entry_ts': sim_idx10[pos['entry_i']], 'exit_ts': last_ts10,
})
# ── 결과 출력 ────────────────────────────────────────────────────────────
if not trades:
print("거래 없음")
return
import collections
by_date = collections.defaultdict(list)
for t in trades:
by_date[t['date']].append(t)
total_krw, total_wins = 0, 0
for date in sorted(by_date.keys()):
day_trades = by_date[date]
day_krw = sum(t['krw'] for t in day_trades)
day_wins = sum(1 for t in day_trades if t['pnl_pct'] > 0)
total_krw += day_krw
total_wins += day_wins
print(f"\n{'='*62}")
print(f"{date}{len(day_trades)}건 | 승률={day_wins/len(day_trades)*100:.0f}% | 일손익={day_krw:+,.0f}")
print(f"{'='*62}")
for t in sorted(day_trades, key=lambda x: x['entry_ts']):
e = '' if t['pnl_pct'] > 0 else ''
print(f" {e} {t['ticker']:12s} "
f"매수={t['buy_price']:,.0f} @ {str(t['entry_ts'])[5:16]}"
f"매도={t['sell_price']:,.0f} @ {str(t['exit_ts'])[5:16]} "
f"| {t['pnl_pct']:+.1f}% ({t['krw']:+,.0f}원) [{t['reason']}]")
print(f"\n{'='*62}")
print(f"【2일 합계】 {len(trades)}건 | "
f"승률={total_wins/len(trades)*100:.0f}% | 총손익={total_krw:+,.0f}")
print(f" [1분봉 스탑 시뮬] VOL≥{VOL_THRESH}x + 횡보<{QUIET_PCT}% → +{THRESH}% 진입 (F&G={FNG})")
def _check_stop(pos, tk, current, ts, i, sim_idx10, trades, positions) -> bool:
"""스탑 체크. 청산 시 True 반환."""
if current > pos['peak']:
pos['peak'] = current
age = i - pos['entry_i']
pnl = (current - pos['buy_price']) / pos['buy_price'] * 100
peak_drop = (pos['peak'] - current) / pos['peak'] * 100
atr_stop = pos['atr_stop']
reason = None
if peak_drop >= atr_stop * 100:
reason = f"트레일링스탑({atr_stop*100:.1f}%)"
elif age >= TS_N and pnl < TIME_STOP_PCT:
reason = f"타임스탑(+{pnl:.1f}%<{TIME_STOP_PCT}%)"
if reason:
fee = pos['invested'] * FEE + current * (pos['invested']/pos['buy_price']) * FEE
krw = current * (pos['invested']/pos['buy_price']) - pos['invested'] - fee
trades.append({
'date': ts.date() if hasattr(ts, 'date') else ts,
'ticker': tk,
'buy_price': pos['buy_price'], 'sell_price': current,
'pnl_pct': pnl, 'krw': krw, 'reason': reason,
'entry_ts': sim_idx10[pos['entry_i']], 'exit_ts': ts,
})
del positions[tk]
return True
return False
# ── main ─────────────────────────────────────────────────────────────────────
conn = _get_conn()
# 1) DB 업데이트
refresh_db(conn)
# 2) DB에서 데이터 로드
print("DB에서 OHLCV 로드 중...", flush=True)
LOAD_SINCE = '2026-03-01 00:00:00'
raw10, raw1 = {}, {}
for tk in TICKERS:
df10 = load_from_db(conn, tk, 'minute10', LOAD_SINCE)
if df10 is not None and len(df10) > LOCAL_VOL_N + QUIET_N:
raw10[tk] = df10
df1 = load_from_db(conn, tk, 'minute1', LOAD_SINCE)
if df1 is not None and len(df1) > 60:
raw1[tk] = df1
print(f"로드 완료: 10분봉 {len(raw10)}종목 / 1분봉 {len(raw1)}종목\n")
conn.close()
# 3) 시뮬 실행
print("="*62)
print(f"하이브리드 시뮬 (신호=10분봉 / 스탑=1분봉) | 2026-03-03 ~ 03-04")
print("="*62)
run_sim(raw10, raw1)