Files
upbit-trader/archive/tests/sim_10m_vol.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

383 lines
14 KiB
Python
Raw 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.
"""10분봉 vol 감지 vs 40분봉 vol 감지 비교 시뮬레이션.
40분봉 집계 시 10분봉 spike가 희석되는 문제를 해결하기 위해
신호 감지를 10분봉 기준으로 실행하고 40분봉 전략과 노이즈/수익 비교.
비교 모드 (각 필터 조합 × 2개 봉 단위):
10분봉 detection:
A. 10m 필터없음
B. 10m F&G≥41 + BEAR차단N5
C. 10m vol≥5x 오버라이드 (F&G+레짐 무시)
40분봉 detection (기준선):
D. 40m 필터없음
E. 40m F&G≥41 + BEAR차단N5
F. 40m vol≥5x 오버라이드
데이터: data/sim1y_cache.pkl (10분봉 1년치)
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import json
import pickle
from pathlib import Path
import pandas as pd
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent.parent / ".env")
CACHE_FILE = Path(__file__).parent.parent / "data" / "sim1y_cache.pkl"
FNG_FILE = Path(__file__).parent.parent / "data" / "fng_1y.json"
TOP_N = 20
BUDGET = 15_000_000
MIN_BUDGET = BUDGET * 3 // 10
MAX_POS = 3
FEE = 0.0005
ATR_MULT = 1.5
ATR_MIN = 0.010
ATR_MAX = 0.020
THRESH = 4.8
QUIET_PCT = 2.0
BEAR_THRESHOLD = -0.5
BULL_THRESHOLD = 1.5
FNG_MIN_ENTRY = 41
# ── 40분봉 파라미터 ────────────────────────────────
P40 = dict(
local_vol_n = 7,
quiet_n = 3,
signal_to_n = 12,
atr_n = 7,
ts_n = 12,
time_stop_pct = 3.0,
vol_mult = 2.0,
)
# ── 10분봉 파라미터 (벽시계 기준 동등) ───────────────
# LOCAL_VOL_N: 40m×7=280min → 10min×28
# QUIET_N: 40m×3=120min → 10min×12
# SIGNAL_TO_N: 40m×12=480min → 10min×48
# ATR_N: 40m×7=280min → 10min×28
# TS_N: 40m×12=480min → 10min×48
P10 = dict(
local_vol_n = 28,
quiet_n = 12,
signal_to_n = 48,
atr_n = 28,
ts_n = 48,
time_stop_pct = 3.0,
vol_mult = 2.0,
)
REGIME_N = 5 # 40분봉 기준
REGIME_WEIGHTS = {"KRW-BTC": 0.40, "KRW-ETH": 0.30,
"KRW-SOL": 0.15, "KRW-XRP": 0.15}
WF_WINDOW = 4
WF_MIN_WIN_RATE = 0.01
WF_SHADOW_WINS = 2
VOL_OVERRIDE_THRESH = 5.0
# ─────────────────────────────────────────────────────────────────────────────
def resample_40m(df):
return (df.resample("40min")
.agg({"open":"first","high":"max","low":"min",
"close":"last","volume":"sum"})
.dropna(subset=["close"]))
def build_regime_series(dfs40):
weighted = None
for ticker, w in REGIME_WEIGHTS.items():
if ticker not in dfs40: continue
pct = dfs40[ticker]["close"].pct_change(REGIME_N) * 100
weighted = pct * w if weighted is None else weighted.add(pct * w, fill_value=0.0)
return weighted if weighted is not None else pd.Series(dtype=float)
def regime_to_10m(regime_40m: pd.Series, df_10m: pd.DataFrame) -> pd.Series:
"""40분봉 레짐 시리즈를 10분봉 인덱스에 ffill 매핑."""
combined = regime_40m.reindex(
regime_40m.index.union(df_10m.index)
).ffill()
return combined.reindex(df_10m.index)
def calc_atr(df, buy_idx, atr_n):
sub = df.iloc[max(0, buy_idx - atr_n - 1):buy_idx]
if len(sub) < 3:
return ATR_MIN
try:
avg = ((sub["high"] - sub["low"]) / sub["low"]).iloc[-atr_n:].mean()
return float(max(ATR_MIN, min(ATR_MAX, avg * ATR_MULT)))
except Exception:
return ATR_MIN
def simulate_pos(df, buy_idx, buy_price, stop_pct, ts_n, time_stop_pct):
peak = buy_price
for i in range(buy_idx + 1, len(df)):
row = df.iloc[i]
if row["high"] > peak:
peak = row["high"]
if row["low"] <= peak * (1 - stop_pct):
sp = peak * (1 - stop_pct)
pnl = (sp*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return pnl > 0, df.index[i], pnl
pnl_now = (row["close"] - buy_price) / buy_price * 100
if (i - buy_idx) >= ts_n and pnl_now < time_stop_pct:
pnl = (row["close"]*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return pnl > 0, df.index[i], pnl
last = df.iloc[-1]["close"]
pnl = (last*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return pnl > 0, df.index[-1], pnl
def run_strategy(df, ticker, regime_series, fng_map, p,
use_fng, use_regime, vol_override_thresh):
"""
공통 전략 함수. df = 봉 단위 OHLCV (10분봉 또는 40분봉).
regime_series: df 인덱스와 정렬된 레짐 시리즈.
우선순위:
① 포지션 청산
② 축적 신호 감지 (필터 무관, 항상 실행)
③ 진입: vol_strong → 모든 필터 skip; 아니면 F&G+레짐 체크
"""
local_vol_n = p["local_vol_n"]
quiet_n = p["quiet_n"]
signal_to_n = p["signal_to_n"]
atr_n = p["atr_n"]
ts_n = p["ts_n"]
time_stop_pct = p["time_stop_pct"]
vol_mult = p["vol_mult"]
trades = []
sig_i = sig_p = sig_vr = None
in_pos = False
buy_idx = buy_price = stop_pct = None
i = max(local_vol_n + 2, quiet_n + 1)
while i < len(df):
ts = df.index[i]
row = df.iloc[i]
cur = row["close"]
# ── ① 포지션 청산 ─────────────────────────────────
if in_pos:
is_win, sdt, pnl = simulate_pos(df, buy_idx, buy_price, stop_pct,
ts_n, time_stop_pct)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, ticker))
in_pos = False; sig_i = sig_p = sig_vr = None; i = next_i
continue
# 신호 만료 체크
if sig_i is not None and (i - sig_i) > signal_to_n:
sig_i = sig_p = sig_vr = None
# ── ② 축적 신호 감지 (항상 실행) ──────────────────────
if sig_i is None:
vol_p = df.iloc[i-1]["volume"]
vol_avg = df.iloc[i-1-local_vol_n:i-1]["volume"].mean()
vol_r = vol_p / vol_avg if vol_avg > 0 else 0
close_qh = df.iloc[i-quiet_n]["close"]
chg_qh = abs(cur - close_qh) / close_qh * 100 if close_qh > 0 else 999
if chg_qh < QUIET_PCT and vol_r >= vol_mult:
sig_i = i; sig_p = cur; sig_vr = vol_r
i += 1
continue
# 신호 이후 가격 하락 → 초기화
if cur < sig_p:
sig_i = sig_p = sig_vr = None
i += 1
continue
# ── ③ 진입 체크 ─────────────────────────────────────
vol_strong = (vol_override_thresh > 0
and sig_vr is not None
and sig_vr >= vol_override_thresh)
if not vol_strong:
# F&G 필터
if use_fng and fng_map:
fv = fng_map.get(ts.strftime("%Y-%m-%d"), 50)
if fv < FNG_MIN_ENTRY:
i += 1
continue
# 레짐 BEAR 차단
if use_regime and not regime_series.empty and ts in regime_series.index:
v = regime_series.loc[ts]
if not pd.isna(v) and float(v) < BEAR_THRESHOLD:
i += 1
continue
move_pct = (cur - sig_p) / sig_p * 100
if move_pct >= THRESH:
in_pos = True; buy_idx = i; buy_price = cur
stop_pct = calc_atr(df, i, atr_n)
sig_i = sig_p = sig_vr = None
i += 1
return trades
def apply_wf(trades):
history = []; shadow = 0; blocked = False; accepted = []; cnt = 0
for t in trades:
is_win = int(t[0])
if not blocked:
accepted.append(t); history.append(is_win)
if len(history) >= WF_WINDOW and sum(history[-WF_WINDOW:]) / WF_WINDOW < WF_MIN_WIN_RATE:
blocked = True; shadow = 0
else:
cnt += 1
if is_win:
shadow += 1
if shadow >= WF_SHADOW_WINS:
blocked = False; history = []; shadow = 0
else:
shadow = 0
return accepted, cnt
def apply_max_pos(trades):
open_exits = []; accepted = []; skipped = []
for t in trades:
buy_dt, sell_dt = t[2], t[3]
open_exits = [s for s in open_exits if s > buy_dt]
if len(open_exits) < MAX_POS:
open_exits.append(sell_dt); accepted.append(t)
else:
skipped.append(t)
return accepted, skipped
def run_compound(accepted):
portfolio = float(BUDGET); total_krw = 0.0
wins = 0; peak = BUDGET; max_dd = 0.0; pf = float(BUDGET)
for is_win, pnl, buy_dt, sell_dt, ticker in accepted:
pos_size = max(portfolio, MIN_BUDGET) / MAX_POS
krw_profit = pos_size * pnl / 100
portfolio = max(portfolio + krw_profit, MIN_BUDGET)
total_krw += krw_profit
wins += int(is_win)
pf = max(pf + max(pf, MIN_BUDGET) / MAX_POS * pnl / 100, MIN_BUDGET)
peak = max(peak, pf)
max_dd = max(max_dd, (peak - pf) / peak * 100)
return {
"portfolio": portfolio, "total_krw": total_krw,
"roi_pct": (portfolio - BUDGET) / BUDGET * 100,
"total": len(accepted), "wins": wins,
"wr": wins / len(accepted) * 100 if accepted else 0,
"max_dd": max_dd,
}
def sim_mode(dfs_per_ticker, regime_map, fng_map, p,
use_fng, use_regime, vol_override_thresh):
"""
dfs_per_ticker: {ticker: DataFrame (10m 또는 40m)}
regime_map: {ticker: regime Series (같은 인덱스로 정렬됨)}
"""
all_trades = []; wf_total = 0
for ticker, df in dfs_per_ticker.items():
rs = regime_map.get(ticker, pd.Series(dtype=float))
raw = run_strategy(df, ticker, rs, fng_map, p,
use_fng, use_regime, vol_override_thresh)
filtered, blocked = apply_wf(raw)
wf_total += blocked
all_trades.extend(filtered)
all_trades.sort(key=lambda x: x[2])
accepted, skipped = apply_max_pos(all_trades)
return run_compound(accepted), wf_total, len(skipped)
def fmt(r, wf, skip):
if r["total"] == 0:
return "진입없음"
return (f"{r['total']:>5}{r['wr']:>4.1f}% "
f"{r['roi_pct']:>+7.2f}% {r['total_krw']:>+13,.0f}"
f"-{r['max_dd']:>4.1f}% wf:{wf} skip:{skip}")
def main():
print("캐시 로드 중...")
cache = pickle.load(open(CACHE_FILE, "rb"))
fng_map = json.loads(FNG_FILE.read_text())
tickers = [t for t in list(cache["10m"].keys())[:TOP_N]
if len(cache["10m"][t]) > 500]
print(f" 종목: {len(tickers)}")
dfs10 = {t: cache["10m"][t] for t in tickers}
dfs40 = {t: resample_40m(df) for t, df in dfs10.items()}
# 레짐 (40분봉 기반)
regime_40m = build_regime_series(dfs40)
# 40분봉용 regime map
regime_map_40 = {t: regime_40m for t in tickers}
# 10분봉용 regime map (ffill 매핑)
regime_map_10 = {
t: regime_to_10m(regime_40m, dfs10[t])
for t in tickers
}
sample = next(iter(dfs10.values()))
start_dt = sample.index[0].strftime("%Y-%m-%d")
end_dt = sample.index[-1].strftime("%Y-%m-%d")
print(f"\n{'='*80}")
print(f" 10분봉 vs 40분봉 vol 감지 비교 | {start_dt} ~ {end_dt} | {len(tickers)}종목")
print(f" vol override: ≥{VOL_OVERRIDE_THRESH}x | F&G≥{FNG_MIN_ENTRY} | BEAR차단N{REGIME_N}")
print(f"{'='*80}")
print(f" {'모드':<32}{'진입':>5} {'승률':>5}{'수익률':>8} {'순수익(KRW)':>14} {'낙폭':>6}")
print(f" {''*76}")
# ── 10분봉 모드들 ─────────────────────────────────────────────────────
print(f"\n [10분봉 vol 감지 — local_vol_n={P10['local_vol_n']}봉({P10['local_vol_n']*10}분) quiet_n={P10['quiet_n']}봉({P10['quiet_n']*10}분)]")
modes_10 = [
("A. 10m 필터없음", False, False, 0.0),
("B. 10m F&G+BEAR차단", True, True, 0.0),
(f"C. 10m vol≥{VOL_OVERRIDE_THRESH}x 오버라이드", True, True, VOL_OVERRIDE_THRESH),
]
for label, uf, ur, vt in modes_10:
r, wf, skip = sim_mode(dfs10, regime_map_10, fng_map, P10, uf, ur, vt)
print(f" {label:<32}{fmt(r, wf, skip)}")
# ── 40분봉 모드들 (기준선) ──────────────────────────────────────────
print(f"\n [40분봉 vol 감지 — local_vol_n={P40['local_vol_n']}봉({P40['local_vol_n']*40}분) quiet_n={P40['quiet_n']}봉({P40['quiet_n']*40}분)]")
modes_40 = [
("D. 40m 필터없음", False, False, 0.0),
("E. 40m F&G+BEAR차단", True, True, 0.0),
(f"F. 40m vol≥{VOL_OVERRIDE_THRESH}x 오버라이드", True, True, VOL_OVERRIDE_THRESH),
]
for label, uf, ur, vt in modes_40:
r, wf, skip = sim_mode(dfs40, regime_map_40, fng_map, P40, uf, ur, vt)
print(f" {label:<32}{fmt(r, wf, skip)}")
print(f"\n{'='*80}")
# ── vol≥5x 신호 품질 비교 (10m vs 40m) ─────────────────────────────
print("\n [vol≥5x 오버라이드 신호 품질 비교 — 필터 없음 조건에서 override 효과]")
print(f" {'모드':<32}{'진입':>5} {'승률':>5}{'수익률':>8} {'낙폭':>6}")
for label, dfs, rmap, p in [
("10m vol≥5x (filter none)", dfs10, regime_map_10, P10),
("40m vol≥5x (filter none)", dfs40, regime_map_40, P40),
]:
r, wf, skip = sim_mode(dfs, rmap, fng_map, p, False, False, VOL_OVERRIDE_THRESH)
if r["total"]:
print(f" {label:<32}{r['total']:>5}{r['wr']:>4.1f}% "
f"{r['roi_pct']:>+7.2f}% -{r['max_dd']:>4.1f}%")
print(f"{'='*80}")
if __name__ == "__main__":
main()