Files
upbit-trader/archive/tests/sweep_volaccel.py
joungmin 6e0c4508fa 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>
2026-03-06 20:46:47 +09:00

356 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""1분봉 볼륨 가속 전략 파라미터 스윕.
시그널 조건: N봉 연속으로 가격 AND 거래량이 함께 증가
봉[n-(N-1)] < 봉[n-(N-2)] < ... < 봉[n]
- 각 봉: 양봉 (close > open)
- 가격 연속 상승: close[k] > close[k-1]
- 볼륨 연속 증가: vol_ratio[k] > vol_ratio[k-1]
- 현재봉(가장 강한 봉) vol_ratio >= VOL_MIN (pre-filter)
진입: 봉[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_ratio 최솟값 — 가속의 "끝점" 기준
VOL_SWEEP = [2.0, 3.0, 4.0, 5.0, 8.0, 10.0, 15.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
# ── 시뮬 구간 ─────────────────────────────────────────────────────────────────
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 생성 ──────────────────────────────────────────────────────────────
# 조건: 가격 연속 상승 + 볼륨 연속 증가 (+ 양봉)
# vol_ratio_0 > vol_ratio_1 > ... > vol_ratio_(n-1)
# close_0 > close_1 > ... > close_(n-1)
# 모두 Oracle SQL에서 처리
def build_sql(n: int) -> str:
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)
)
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)
lag_passthrough = "\n".join(
f" prev_close_{i}, prev_open_{i},"
for i in range(1, n)
)
# 조건: 양봉 + 가격 연속 상승 + 볼륨 연속 증가
cond_lines = [
" AND vol_ratio_0 >= :min_vol", # pre-filter: 현재봉(최강봉)
" AND close_p > open_p", # 현재봉 양봉
]
for i in range(1, n):
cond_lines.append(f" AND prev_close_{i} > prev_open_{i}") # 이전봉 양봉
if i == 1:
cond_lines.append(f" AND close_p > prev_close_{i}") # 가격 상승
cond_lines.append(f" AND vol_ratio_0 > vol_ratio_{i}") # 볼륨 증가
else:
cond_lines.append(f" AND prev_close_{i-1} > prev_close_{i}") # 가격 상승
cond_lines.append(f" AND vol_ratio_{i-1} > vol_ratio_{i}") # 볼륨 증가
cond_str = "\n".join(cond_lines)
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"볼륨 가속 전략 (현재봉 VOL>={VOL_MIN}x, N={N_BARS_LIST}, 가격·볼륨 동시 가속)\n", flush=True)
conn = _get_conn()
cur = conn.cursor()
cur.arraysize = 100_000
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 = 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])
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,
'vr0': vr_vals[0], # 현재봉 vol (가장 강한 봉)
'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:
# 현재봉(최강봉) vol 기준으로 필터
if e['vr0'] < 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.1f}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.1f}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()