- 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>
358 lines
14 KiB
Python
358 lines
14 KiB
Python
"""1분봉 N봉 연속 상승 vol spike 전략 파라미터 스윕.
|
||
|
||
N=2/3/4 연속 조건 (양봉 + 상승 + vol spike) 모두 Oracle SQL에서 처리.
|
||
Python은 ATR 파라미터 + VOL 임계값 스윕만 담당.
|
||
|
||
시그널 조건 (N_BARS=N):
|
||
봉[n-(N-1)]~봉[n]: 모두 vol_ratio >= VOL_MIN, 양봉, 연속 상승
|
||
진입: 봉[n+1] close 즉시
|
||
추적: 1분봉 trail stop (ATR) + time stop / 월별 배치
|
||
"""
|
||
import sys, os, itertools
|
||
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 pandas as pd
|
||
import oracledb
|
||
import time as _time
|
||
|
||
# ── 고정 파라미터 ─────────────────────────────────────────────────────────────
|
||
VOL_LOOKBACK = 61
|
||
ATR_LOOKBACK = 28
|
||
TS_N = 240
|
||
TIME_STOP_PCT = 0.0 / 100
|
||
FEE = 0.0005
|
||
BUDGET = 15_000_000
|
||
MAX_POS = 3
|
||
PER_POS = BUDGET // MAX_POS
|
||
|
||
# ── 스윕 파라미터 ─────────────────────────────────────────────────────────────
|
||
VOL_SWEEP = [1.5, 2.0, 3.0, 5.0, 8.0, 10.0, 15.0, 20.0, 25.0]
|
||
ATR_SWEEP = {
|
||
"ATR_MULT": [1.5, 2.0, 2.5, 3.0],
|
||
"ATR_MIN": [0.005, 0.010, 0.015],
|
||
"ATR_MAX": [0.020, 0.025, 0.030],
|
||
}
|
||
N_BARS_LIST = [2, 3, 4]
|
||
VOL_MIN = min(VOL_SWEEP) # SQL pre-filter (모든 봉 >= 1.5x) (모든 봉에 적용)
|
||
|
||
# ── 시뮬 구간 ─────────────────────────────────────────────────────────────────
|
||
SIM_START = datetime(2025, 8, 1)
|
||
SIM_END = datetime(2026, 3, 4)
|
||
WARMUP_MINS = 120
|
||
|
||
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',
|
||
]
|
||
_TK = ",".join(f"'{t}'" for t in TICKERS)
|
||
|
||
|
||
def _months(start: datetime, end: datetime):
|
||
m = start.replace(day=1)
|
||
while m < end:
|
||
nxt = (m + timedelta(days=32)).replace(day=1)
|
||
if nxt > end:
|
||
nxt = end
|
||
yield m, nxt
|
||
m = nxt
|
||
|
||
|
||
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)
|
||
|
||
|
||
# ── N별 SQL 생성 ──────────────────────────────────────────────────────────────
|
||
# 공통 CTE: base(TR + LAG N개), indicators(vol_ratio N개 + ATR)
|
||
# signals: N봉 연속 조건 모두 SQL에서 처리
|
||
# vol_ratio 컬럼은 Python VOL 스윕용으로 모두 반환
|
||
|
||
def build_sql(n: int) -> str:
|
||
"""n봉 연속 조건을 SQL로 구현. 반환 컬럼에 vol_ratio 0~n-1 포함."""
|
||
|
||
# LAG 컬럼 정의 (base CTE)
|
||
lag_cols = "\n".join(
|
||
f" LAG(close_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_close_{i},\n"
|
||
f" LAG(open_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_open_{i},\n"
|
||
f" LAG(volume_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_{i},"
|
||
for i in range(1, n)
|
||
)
|
||
|
||
# indicators CTE: vol_ratio 0 ~ n-1
|
||
vr_cols = []
|
||
vr_cols.append(f""" volume_p / NULLIF(
|
||
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
|
||
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
|
||
) vol_ratio_0,""")
|
||
for i in range(1, n):
|
||
vr_cols.append(f""" prev_vol_{i} / NULLIF(
|
||
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
|
||
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
|
||
) vol_ratio_{i},""")
|
||
vr_cols_str = "\n".join(vr_cols)
|
||
|
||
# pass-through LAG 컬럼 from base → indicators
|
||
lag_passthrough = "\n".join(
|
||
f" prev_close_{i}, prev_open_{i},"
|
||
for i in range(1, n)
|
||
)
|
||
|
||
# signals WHERE 조건: 현재봉 + 이전 n-1봉 all 양봉 + 연속 상승 + vol
|
||
cond_lines = [
|
||
" AND vol_ratio_0 >= :min_vol",
|
||
" AND close_p > open_p", # 현재봉 양봉
|
||
]
|
||
for i in range(1, n):
|
||
cond_lines.append(f" AND vol_ratio_{i} >= :min_vol")
|
||
cond_lines.append(f" AND prev_close_{i} > prev_open_{i}") # 양봉
|
||
if i == 1:
|
||
cond_lines.append(f" AND close_p > prev_close_{i}") # 현재봉 > 1봉전
|
||
else:
|
||
cond_lines.append(f" AND prev_close_{i-1} > prev_close_{i}") # 상승 연속
|
||
cond_str = "\n".join(cond_lines)
|
||
|
||
# SELECT: vol_ratio 0~n-1 반환 (Python VOL 스윕용)
|
||
vr_select = ", ".join(f"vol_ratio_{i}" for i in range(n))
|
||
|
||
return f"""
|
||
WITH
|
||
base AS (
|
||
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
|
||
{lag_cols}
|
||
GREATEST(
|
||
high_p - low_p,
|
||
ABS(high_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
|
||
ABS(low_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))
|
||
) tr
|
||
FROM backtest_ohlcv
|
||
WHERE interval_cd = 'minute1'
|
||
AND ts >= TO_TIMESTAMP(:load_since, 'YYYY-MM-DD HH24:MI:SS')
|
||
AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS')
|
||
AND ticker IN ({_TK})
|
||
),
|
||
indicators AS (
|
||
SELECT ticker, ts, open_p, close_p,
|
||
{lag_passthrough}
|
||
{vr_cols_str}
|
||
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
|
||
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING)
|
||
/ NULLIF(prev_close_1, 0) atr_raw
|
||
FROM base
|
||
),
|
||
signals AS (
|
||
SELECT ticker, ts sig_ts, close_p sig_price,
|
||
{vr_select}, atr_raw
|
||
FROM indicators
|
||
WHERE ts >= TO_TIMESTAMP(:sim_start, 'YYYY-MM-DD HH24:MI:SS')
|
||
AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS')
|
||
{cond_str}
|
||
),
|
||
entry_cands AS (
|
||
SELECT s.ticker, s.sig_ts, {vr_select.replace('vol_ratio_', 's.vol_ratio_')}, s.atr_raw,
|
||
e.ts entry_ts, e.close_p entry_price,
|
||
ROW_NUMBER() OVER (PARTITION BY s.ticker, s.sig_ts ORDER BY e.ts) rn
|
||
FROM signals s
|
||
JOIN backtest_ohlcv e
|
||
ON e.ticker = s.ticker
|
||
AND e.interval_cd = 'minute1'
|
||
AND e.ts > s.sig_ts
|
||
AND e.ts <= s.sig_ts + INTERVAL '3' MINUTE
|
||
),
|
||
entries AS (
|
||
SELECT ticker, sig_ts, {vr_select}, atr_raw, entry_ts, entry_price
|
||
FROM entry_cands WHERE rn = 1
|
||
),
|
||
post_entry AS (
|
||
SELECT
|
||
e.ticker, e.sig_ts, e.entry_ts, e.entry_price,
|
||
{vr_select.replace('vol_ratio_', 'e.vol_ratio_')}, e.atr_raw,
|
||
b.close_p bar_price,
|
||
ROW_NUMBER() OVER (PARTITION BY e.ticker, e.entry_ts ORDER BY b.ts) bar_n,
|
||
MAX(b.close_p) OVER (PARTITION BY e.ticker, e.entry_ts
|
||
ORDER BY b.ts ROWS UNBOUNDED PRECEDING) running_peak
|
||
FROM entries e
|
||
JOIN backtest_ohlcv b
|
||
ON b.ticker = e.ticker
|
||
AND b.interval_cd = 'minute1'
|
||
AND b.ts >= e.entry_ts
|
||
AND b.ts <= e.entry_ts + INTERVAL '{TS_N}' MINUTE
|
||
)
|
||
SELECT ticker, sig_ts, entry_ts, entry_price,
|
||
{vr_select}, atr_raw,
|
||
bar_n, bar_price, running_peak
|
||
FROM post_entry
|
||
WHERE bar_n <= :ts_n + 1
|
||
ORDER BY ticker, entry_ts, bar_n
|
||
"""
|
||
|
||
|
||
# ── 월별 데이터 로드 ──────────────────────────────────────────────────────────
|
||
print(f"N봉 연속 vol spike 전략 (VOL>={VOL_MIN}x, N={N_BARS_LIST})\n", flush=True)
|
||
|
||
conn = _get_conn()
|
||
cur = conn.cursor()
|
||
cur.arraysize = 100_000
|
||
|
||
# N별로 별도 딕셔너리
|
||
ALL_ENTRIES: dict[int, dict] = {n: {} for n in N_BARS_LIST}
|
||
t_load = _time.time()
|
||
|
||
for n in N_BARS_LIST:
|
||
sql = build_sql(n)
|
||
print(f"── {n}봉 로드 중... ──────────────────────────", flush=True)
|
||
n_total = 0
|
||
|
||
for m_start, m_end in _months(SIM_START, SIM_END):
|
||
load_since = (m_start - timedelta(minutes=WARMUP_MINS)).strftime('%Y-%m-%d %H:%M:%S')
|
||
sim_start = m_start.strftime('%Y-%m-%d %H:%M:%S')
|
||
sim_end = m_end.strftime('%Y-%m-%d %H:%M:%S')
|
||
|
||
t0 = _time.time()
|
||
cur.execute(sql, {
|
||
"load_since": load_since,
|
||
"sim_start": sim_start,
|
||
"sim_end": sim_end,
|
||
"min_vol": VOL_MIN,
|
||
"ts_n": TS_N,
|
||
})
|
||
rows = cur.fetchall()
|
||
t1 = _time.time()
|
||
|
||
n_new = 0
|
||
for row in rows:
|
||
# ticker, sig_ts, entry_ts, entry_price, vr0..vr(n-1), atr_raw, bar_n, bar_price, peak
|
||
ticker = row[0]
|
||
entry_ts = row[2]
|
||
entry_price= float(row[3])
|
||
vr_vals = [float(row[4 + i]) if row[4 + i] is not None else 0.0 for i in range(n)]
|
||
atr_raw = row[4 + n]
|
||
bar_price = float(row[4 + n + 2]) # bar_n is at 4+n+1
|
||
running_peak = float(row[4 + n + 3])
|
||
|
||
key = (ticker, entry_ts)
|
||
if key not in ALL_ENTRIES[n]:
|
||
ALL_ENTRIES[n][key] = {
|
||
'entry_price': entry_price,
|
||
'vr': vr_vals,
|
||
'atr_raw': float(atr_raw) if atr_raw is not None else float('nan'),
|
||
'bars': [],
|
||
}
|
||
n_new += 1
|
||
ALL_ENTRIES[n][key]['bars'].append((bar_price, running_peak))
|
||
|
||
n_total += n_new
|
||
print(f" {sim_start[:7]}: {len(rows):>8,}행 ({t1-t0:.1f}s) | 진입 {n_new:>5}건", flush=True)
|
||
|
||
print(f" → {n}봉 합계: {n_total}건\n", flush=True)
|
||
|
||
conn.close()
|
||
print(f"전체 로드 완료 ({_time.time()-t_load:.1f}s)\n", flush=True)
|
||
|
||
|
||
# ── 출구 탐색 ─────────────────────────────────────────────────────────────────
|
||
def find_exit(entry_price: float, atr_stop: float, bars: list) -> float:
|
||
for i, (bp, pk) in enumerate(bars):
|
||
drop = (pk - bp) / pk if pk > 0 else 0.0
|
||
pnl = (bp - entry_price) / entry_price
|
||
if drop >= atr_stop:
|
||
return pnl * 100
|
||
if i + 1 >= TS_N and pnl < TIME_STOP_PCT:
|
||
return pnl * 100
|
||
return (bars[-1][0] - entry_price) / entry_price * 100 if bars else 0.0
|
||
|
||
|
||
# ── 스윕 ──────────────────────────────────────────────────────────────────────
|
||
atr_keys = list(ATR_SWEEP.keys())
|
||
atr_combos = list(itertools.product(*ATR_SWEEP.values()))
|
||
total_combos = len(N_BARS_LIST) * len(VOL_SWEEP) * len(atr_combos)
|
||
print(f"총 {total_combos}가지 조합 스윕...\n", flush=True)
|
||
|
||
t_sweep = _time.time()
|
||
results = []
|
||
|
||
for n in N_BARS_LIST:
|
||
entry_list = list(ALL_ENTRIES[n].values())
|
||
for vol in VOL_SWEEP:
|
||
for atr_combo in atr_combos:
|
||
atr_params = dict(zip(atr_keys, atr_combo))
|
||
if atr_params['ATR_MIN'] >= atr_params['ATR_MAX']:
|
||
continue
|
||
|
||
atr_mult = atr_params['ATR_MULT']
|
||
atr_min = atr_params['ATR_MIN']
|
||
atr_max = atr_params['ATR_MAX']
|
||
|
||
trades = []
|
||
for e in entry_list:
|
||
# Python: vol 임계값 최종 필터 (모든 봉 vol >= vol)
|
||
if min(e['vr']) < vol:
|
||
continue
|
||
|
||
ar = e['atr_raw']
|
||
atr_s = (atr_min if (ar != ar)
|
||
else max(atr_min, min(atr_max, ar * atr_mult)))
|
||
pnl_pct = find_exit(e['entry_price'], atr_s, e['bars'])
|
||
krw = PER_POS * (pnl_pct / 100) - PER_POS * FEE * 2
|
||
trades.append((pnl_pct, krw))
|
||
|
||
if not trades:
|
||
results.append({'N_BARS': n, 'VOL': vol, **atr_params,
|
||
'trades': 0, 'wins': 0,
|
||
'win_rate': 0.0, 'avg_pnl': 0.0, 'total_krw': 0.0})
|
||
continue
|
||
|
||
wins = sum(1 for p, _ in trades if p > 0)
|
||
results.append({
|
||
'N_BARS': n,
|
||
'VOL': vol,
|
||
**atr_params,
|
||
'trades': len(trades),
|
||
'wins': wins,
|
||
'win_rate': wins / len(trades) * 100,
|
||
'avg_pnl': sum(p for p, _ in trades) / len(trades),
|
||
'total_krw': sum(k for _, k in trades),
|
||
})
|
||
|
||
print(f"스윕 완료 ({_time.time()-t_sweep:.1f}s)\n")
|
||
|
||
# ── 결과 출력 ─────────────────────────────────────────────────────────────────
|
||
df_r = pd.DataFrame(results)
|
||
df_r = df_r[df_r['trades'] > 0].sort_values('total_krw', ascending=False)
|
||
|
||
print("=" * 105)
|
||
print(f"{'순위':>4} {'N봉':>4} {'VOL':>5} {'ATR_M':>6} {'ATR_N':>6} {'ATR_X':>6} "
|
||
f"{'건수':>5} {'승률':>6} {'평균PNL':>8} {'총손익':>14}")
|
||
print("=" * 105)
|
||
for rank, (_, row) in enumerate(df_r.head(30).iterrows(), 1):
|
||
print(f"{rank:>4} {int(row['N_BARS']):>2}봉 {row['VOL']:>4.0f}x {row['ATR_MULT']:>6.1f} "
|
||
f"{row['ATR_MIN']*100:>5.1f}% {row['ATR_MAX']*100:>5.1f}% "
|
||
f"{int(row['trades']):>5}건 {row['win_rate']:>5.0f}% "
|
||
f"{row['avg_pnl']:>+7.2f}% {row['total_krw']:>+14,.0f}원")
|
||
|
||
# N봉 × VOL별 최상위 요약
|
||
print("\n" + "─" * 85)
|
||
print(f" {'N봉':>3} {'VOL':>5} {'건수':>5} {'승률':>5} {'평균PNL':>8} {'총손익':>14} (최적 ATR)")
|
||
print("─" * 85)
|
||
for n in N_BARS_LIST:
|
||
for vol in VOL_SWEEP:
|
||
sub = df_r[(df_r['N_BARS'] == n) & (df_r['VOL'] == vol)]
|
||
if sub.empty:
|
||
continue
|
||
best = sub.iloc[0]
|
||
if best['trades'] == 0:
|
||
continue
|
||
print(f" {int(n):>2}봉 {vol:>4.0f}x {int(best['trades']):>5}건 {best['win_rate']:>4.0f}% "
|
||
f"{best['avg_pnl']:>+7.2f}% {best['total_krw']:>+14,.0f}원 "
|
||
f"(M={best['ATR_MULT']:.1f} N={best['ATR_MIN']*100:.1f}% X={best['ATR_MAX']*100:.1f}%)")
|
||
print()
|