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

357
archive/tests/sweep_nbar.py Normal file
View File

@@ -0,0 +1,357 @@
"""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()