- 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>
266 lines
11 KiB
Python
266 lines
11 KiB
Python
"""진입 즉시 limit 매도 주문 → N봉 내 미체결 시 TP/Trail 전환 시뮬 (30일).
|
||
|
||
전략:
|
||
1. 3봉 vol가속 시그널 → 진입
|
||
2. 즉시 limit_price = entry_price × (1 + limit_r) 에 limit 매도 주문
|
||
3. N봉 안에 high_p >= limit_price → 체결 (limit_price에 청산)
|
||
4. N봉 안에 미체결 → TP 2% + Trail Stop 으로 전환
|
||
"""
|
||
import sys, os
|
||
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 oracledb
|
||
|
||
LOOKBACK_DAYS = 30
|
||
VOL_LOOKBACK = 61
|
||
ATR_LOOKBACK = 28
|
||
VOL_MIN = 8.0
|
||
ATR_MULT = 1.0
|
||
ATR_MIN_R = 0.030
|
||
ATR_MAX_R = 0.050
|
||
MAX_TRAIL_BARS = 240
|
||
BUDGET = 15_000_000
|
||
MAX_POS = 3
|
||
PER_POS = BUDGET // MAX_POS
|
||
FEE = 0.0005
|
||
|
||
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 get_conn():
|
||
kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"],
|
||
dsn=os.environ["ORACLE_DSN"])
|
||
if w := os.environ.get("ORACLE_WALLET"):
|
||
kwargs["config_dir"] = w
|
||
return oracledb.connect(**kwargs)
|
||
|
||
|
||
SIGNAL_SQL = f"""
|
||
WITH base AS (
|
||
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
|
||
LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts) pc1,
|
||
LAG(open_p,1) OVER (PARTITION BY ticker ORDER BY ts) po1,
|
||
LAG(close_p,2) OVER (PARTITION BY ticker ORDER BY ts) pc2,
|
||
LAG(open_p,2) OVER (PARTITION BY ticker ORDER BY ts) po2,
|
||
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(:ws,'YYYY-MM-DD HH24:MI:SS')
|
||
AND ticker IN ({_TK})
|
||
),
|
||
ind AS (
|
||
SELECT ticker, ts, open_p, close_p, high_p, low_p,
|
||
volume_p / NULLIF(AVG(volume_p) OVER (
|
||
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr0,
|
||
LAG(volume_p,1) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
|
||
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr1,
|
||
LAG(volume_p,2) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
|
||
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr2,
|
||
pc1,po1,pc2,po2,
|
||
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
|
||
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) / NULLIF(pc1,0) atr_raw
|
||
FROM base
|
||
)
|
||
SELECT ticker, ts, vr0, vr1, vr2, atr_raw
|
||
FROM ind
|
||
WHERE ts >= TO_TIMESTAMP(:cs,'YYYY-MM-DD HH24:MI:SS')
|
||
AND vr0 >= {VOL_MIN}
|
||
AND close_p>open_p AND pc1>po1 AND pc2>po2
|
||
AND close_p>pc1 AND pc1>pc2
|
||
AND vr0>vr1 AND vr1>vr2
|
||
ORDER BY ticker, ts
|
||
"""
|
||
|
||
|
||
def fetch_signals(cur, warmup_since, check_since):
|
||
cur.execute(SIGNAL_SQL, {'ws': warmup_since, 'cs': check_since})
|
||
rows = cur.fetchall()
|
||
signals = []
|
||
for row in rows:
|
||
ticker, sig_ts, vr0, vr1, vr2, atr_raw = row
|
||
cur.execute(
|
||
"""SELECT close_p, ts FROM backtest_ohlcv
|
||
WHERE ticker=:t AND interval_cd='minute1'
|
||
AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE
|
||
ORDER BY ts FETCH FIRST 1 ROWS ONLY""",
|
||
{'t': ticker, 'sig': sig_ts}
|
||
)
|
||
er = cur.fetchone()
|
||
if not er:
|
||
continue
|
||
ep, ets = float(er[0]), er[1]
|
||
cur.execute(
|
||
"""SELECT ts, close_p, high_p, low_p FROM backtest_ohlcv
|
||
WHERE ticker=:t AND interval_cd='minute1'
|
||
AND ts >= :entry ORDER BY ts FETCH FIRST :n ROWS ONLY""",
|
||
{'t': ticker, 'entry': ets, 'n': MAX_TRAIL_BARS + 1}
|
||
)
|
||
bars = [(r[0], float(r[1]), float(r[2]), float(r[3])) for r in cur.fetchall()]
|
||
if not bars:
|
||
continue
|
||
signals.append({
|
||
'ticker': ticker, 'entry_ts': ets, 'entry_price': ep,
|
||
'atr_raw': float(atr_raw) if atr_raw else 0.0,
|
||
'bars': bars,
|
||
})
|
||
signals.sort(key=lambda x: x['entry_ts'])
|
||
return signals
|
||
|
||
|
||
# ── 청산 전략 ─────────────────────────────────────────────────────────────────
|
||
|
||
def sim_tp_trail(bars, ep, ar, tp_r=0.02):
|
||
"""기본 전략: TP + Trail Stop."""
|
||
stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
|
||
tp = ep * (1 + tp_r)
|
||
peak = ep
|
||
for i, (ts, cp, hp, lp) in enumerate(bars):
|
||
if hp >= tp:
|
||
return dict(status=f'TP{tp_r*100:.0f}%', exit_ts=ts, exit_price=tp,
|
||
pnl=tp_r * 100, held=i + 1)
|
||
peak = max(peak, cp)
|
||
if (peak - cp) / peak >= stop:
|
||
return dict(status='트레일', exit_ts=ts, exit_price=cp,
|
||
pnl=(cp - ep) / ep * 100, held=i + 1)
|
||
lts, lcp = bars[-1][0], bars[-1][1]
|
||
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
|
||
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
|
||
|
||
|
||
def sim_limit_then_trail(bars, ep, ar, n_bars, limit_r, tp_r=0.02):
|
||
"""진입 즉시 limit_price에 매도 주문 → N봉 내 체결 안되면 TP/Trail 전환.
|
||
|
||
체결 조건: high_p >= limit_price → limit_price에 청산 (실현 가능한 가격)
|
||
"""
|
||
limit_price = ep * (1 + limit_r)
|
||
window = bars[:n_bars]
|
||
|
||
for i, (ts, cp, hp, lp) in enumerate(window):
|
||
if hp >= limit_price:
|
||
pnl = (limit_price - ep) / ep * 100
|
||
return dict(status=f'limit체결({n_bars}봉)', exit_ts=ts,
|
||
exit_price=limit_price, pnl=pnl, held=i + 1)
|
||
|
||
# N봉 내 미체결 → TP/Trail 전환
|
||
fallback = sim_tp_trail(bars[n_bars:] or bars[-1:], ep, ar, tp_r)
|
||
fallback['status'] = f'미체결→{fallback["status"]}'
|
||
fallback['held'] += n_bars
|
||
return fallback
|
||
|
||
|
||
def pos_limit(sim):
|
||
opens, taken, skipped = [], [], []
|
||
for r in sim:
|
||
opens = [ex for ex in opens if ex > r['entry_ts']]
|
||
if len(opens) < MAX_POS:
|
||
opens.append(r['exit_ts'])
|
||
taken.append(r)
|
||
else:
|
||
skipped.append(r)
|
||
return taken, skipped
|
||
|
||
|
||
def stats(taken):
|
||
n = len(taken)
|
||
if n == 0:
|
||
return None
|
||
wins = sum(1 for r in taken if r['pnl'] > 0)
|
||
losses = sum(1 for r in taken if r['pnl'] < 0)
|
||
total = sum(PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 for r in taken)
|
||
avg_h = sum(r['held'] for r in taken) / n
|
||
ret = total / BUDGET * 100
|
||
avg_w = sum(r['pnl'] for r in taken if r['pnl'] > 0) / wins if wins else 0
|
||
avg_l = abs(sum(r['pnl'] for r in taken if r['pnl'] < 0) / losses) if losses else 1
|
||
fill_n = sum(1 for r in taken if 'limit체결' in r['status'])
|
||
return dict(n=n, wins=wins, wr=wins/n*100, total=total, ret=ret, avg_h=avg_h,
|
||
avg_w=avg_w, avg_l=avg_l, rr=avg_w/avg_l if avg_l else 0,
|
||
fill_r=fill_n/n*100)
|
||
|
||
|
||
def main():
|
||
now = datetime.now()
|
||
check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00')
|
||
warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00')
|
||
|
||
conn = get_conn()
|
||
cur = conn.cursor()
|
||
cur.arraysize = 10000
|
||
|
||
print(f"=== Limit 주문 전략 시뮬 (3봉 진입) ===")
|
||
print(f"기간: {check_since[:10]} ~ {now.strftime('%Y-%m-%d')} (30일)\n")
|
||
|
||
signals = fetch_signals(cur, warmup_since, check_since)
|
||
print(f"시그널 {len(signals)}건\n")
|
||
|
||
# 비교 전략 목록: (label, limit_r, n_bars)
|
||
strategies = [
|
||
('현재전략: TP 2% + Trail', None, None, None),
|
||
('limit 0.5% / 2봉, 미체결→TP/Trail', 0.005, 2, 0.02),
|
||
('limit 0.5% / 3봉, 미체결→TP/Trail', 0.005, 3, 0.02),
|
||
('limit 0.5% / 5봉, 미체결→TP/Trail', 0.005, 5, 0.02),
|
||
('limit 1.0% / 2봉, 미체결→TP/Trail', 0.010, 2, 0.02),
|
||
('limit 1.0% / 3봉, 미체결→TP/Trail', 0.010, 3, 0.02),
|
||
('limit 1.0% / 5봉, 미체결→TP/Trail', 0.010, 5, 0.02),
|
||
('limit 1.5% / 2봉, 미체결→TP/Trail', 0.015, 2, 0.02),
|
||
('limit 1.5% / 3봉, 미체결→TP/Trail', 0.015, 3, 0.02),
|
||
('limit 1.5% / 5봉, 미체결→TP/Trail', 0.015, 5, 0.02),
|
||
('limit 2.0% / 3봉, 미체결→TP/Trail', 0.020, 3, 0.02),
|
||
('limit 2.0% / 5봉, 미체결→TP/Trail', 0.020, 5, 0.02),
|
||
]
|
||
|
||
results = {}
|
||
for label, limit_r, n_bars, tp_r in strategies:
|
||
sim = []
|
||
for s in signals:
|
||
if limit_r is None:
|
||
r = sim_tp_trail(s['bars'], s['entry_price'], s['atr_raw'])
|
||
else:
|
||
r = sim_limit_then_trail(s['bars'], s['entry_price'], s['atr_raw'],
|
||
n_bars, limit_r, tp_r)
|
||
sim.append({**s, **r})
|
||
taken, _ = pos_limit(sim)
|
||
results[label] = taken
|
||
|
||
# ── 요약표 ──────────────────────────────────────────────────────────────────
|
||
print(f"{'━'*120}")
|
||
print(f" {'전략':40s} {'거래':>3s} {'승률':>4s} {'합산손익':>12s} {'수익률':>5s} "
|
||
f"{'평균보유':>5s} {'체결률':>5s} {'평균수익':>6s} {'평균손실':>6s}")
|
||
print(f"{'━'*120}")
|
||
for label, limit_r, n_bars, tp_r in strategies:
|
||
taken = results[label]
|
||
s = stats(taken)
|
||
if not s:
|
||
continue
|
||
print(f" {label:40s} {s['n']:>3d}건 {s['wr']:>4.0f}% {s['total']:>+12,.0f}원 "
|
||
f"{s['ret']:>+5.2f}% {s['avg_h']:>5.1f}봉 {s['fill_r']:>4.0f}% "
|
||
f"{s['avg_w']:>+5.2f}% {s['avg_l']:>+5.2f}%")
|
||
print(f"{'━'*120}")
|
||
|
||
# ── 최고 전략 상세 ──────────────────────────────────────────────────────────
|
||
best_label = max(results, key=lambda k: sum(PER_POS * (r['pnl']/100) - PER_POS*FEE*2
|
||
for r in results[k]))
|
||
print(f"\n[최고 전략: {best_label} 건별 상세]")
|
||
print(f"{'─'*110}")
|
||
for i, r in enumerate(results[best_label], 1):
|
||
krw = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2
|
||
sign = '▲' if r['pnl'] > 0 else '▼'
|
||
print(f" #{i:02d} {r['ticker']:12s}[{sign}] {str(r['entry_ts'])[:16]} "
|
||
f"진입 {r['entry_price']:>10,.0f}원 "
|
||
f"→ {r['status']:22s} {r['held']:3d}봉 {r['pnl']:>+.2f}% ({krw:>+,.0f}원)")
|
||
|
||
conn.close()
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|