auto: 일일 백업 2026-06-09 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
"""백테스트 엔진 — 과거 일봉을 하루씩 되감으며 signals.py 규칙으로 가상 매매.
|
||||
|
||||
미래정보 차단:
|
||||
- 지표는 그날(t)까지의 봉으로만 계산, 체결은 당일 종가 (다음 봉 미참조).
|
||||
- 애널 게이트는 과거 재현 불가 → 중립(analyst=None, 자동 통과).
|
||||
- 수급은 flow_history(있으면) 사용, 없으면 중립(통과)으로 표시.
|
||||
- 시장 필터(ADR)는 데이터가 짧아 backtest 에선 통과 처리.
|
||||
|
||||
엔진과 동일한 signals.compute_indicators / evaluate_candidate / evaluate_holding /
|
||||
compute_stop_target 를 그대로 재사용한다 (페이퍼와 같은 두뇌).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
from . import config, signals
|
||||
|
||||
sys.path.insert(0, str(config.SCRIPTS))
|
||||
import daily_candles_cache as dcc # noqa: E402
|
||||
|
||||
FLOW_DB = config.STATE_DIR / 'flow_history.sqlite'
|
||||
WINDOW = 140 # 지표 계산에 넘길 최근 봉 수 (성능 상한 — 모든 지표 기간 + 버퍼 충분)
|
||||
|
||||
|
||||
def apply_params(overrides: dict | None):
|
||||
"""튜닝 오버라이드를 config 전역에 반영 (없는 키는 기본값). 각 run 독립 보장."""
|
||||
overrides = overrides or {}
|
||||
for spec in config.TUNABLE:
|
||||
k = spec['key']
|
||||
v = overrides.get(k, config._DEFAULTS[k])
|
||||
setattr(config, k, v)
|
||||
|
||||
|
||||
def load_history(code: str, count: int = 600) -> list[dict]:
|
||||
"""캐시(sqlite)에서 일봉 오름차순. 네트워크 X."""
|
||||
return list(reversed(dcc._select_latest(code, count)))
|
||||
|
||||
|
||||
def load_flow(code: str) -> dict | None:
|
||||
"""flow_history.sqlite 에서 {date: (foreign, institution)}. 없으면 None (수급 중립)."""
|
||||
if not FLOW_DB.exists():
|
||||
return None
|
||||
try:
|
||||
c = sqlite3.connect(FLOW_DB)
|
||||
rows = c.execute('SELECT date, foreign_net, inst_net FROM flow WHERE code=? ORDER BY date', (code,)).fetchall()
|
||||
c.close()
|
||||
return {r[0]: (r[1], r[2]) for r in rows} or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _flow_net_upto(flow: dict | None, dates_seen: list[str]) -> dict:
|
||||
"""최근 FLOW_DAYS 일 외국인·기관 누적. flow 없으면 중립(둘 다 +1 → 수급 통과)."""
|
||||
if flow is None:
|
||||
return {'foreign': 1, 'institution': 1, 'days': 0, 'neutral': True}
|
||||
recent = dates_seen[-config.FLOW_DAYS:]
|
||||
f = sum(flow.get(d, (0, 0))[0] for d in recent)
|
||||
i = sum(flow.get(d, (0, 0))[1] for d in recent)
|
||||
return {'foreign': f, 'institution': i, 'days': len(recent)}
|
||||
|
||||
|
||||
def run(overrides: dict | None, codes: list[str], date_from: str = '', date_to: str = '9',
|
||||
capital: float | None = None) -> dict:
|
||||
"""백테스트 1회. 반환: 지표 dict (+ trades 수)."""
|
||||
apply_params(overrides)
|
||||
capital = capital or config.INITIAL_CAPITAL
|
||||
|
||||
hist = {}
|
||||
for code in codes:
|
||||
h = [c for c in load_history(code) if date_from <= c['date'] <= date_to]
|
||||
if len(h) > config.SMA_LONG + config.ATR_PERIOD + 5:
|
||||
hist[code] = h
|
||||
if not hist:
|
||||
return {'error': 'no_data', 'trades': 0}
|
||||
flows = {code: load_flow(code) for code in hist}
|
||||
|
||||
# code별 date→index, 전체 거래일 축
|
||||
idx_map = {code: {c['date']: i for i, c in enumerate(h)} for code, h in hist.items()}
|
||||
all_dates = sorted({c['date'] for h in hist.values() for c in h})
|
||||
|
||||
cash = capital
|
||||
positions: dict[str, dict] = {}
|
||||
realized = 0.0
|
||||
wins = closed = 0
|
||||
gross_win = gross_loss = 0.0
|
||||
peak_eq = capital
|
||||
max_dd = 0.0
|
||||
comm, tax = config.COMMISSION_RATE, config.SELL_TAX_RATE
|
||||
|
||||
def equity(day_prices):
|
||||
return cash + sum(p['qty'] * day_prices.get(c, p['entry_price']) for c, p in positions.items())
|
||||
|
||||
def _qty_for(fill, value):
|
||||
return int(value // (fill * (1 + comm)))
|
||||
|
||||
for day in all_dates:
|
||||
day_prices = {}
|
||||
# ---- 보유 판단 (당일 종가 기준): 손절/익절(전량·분할) → 추격매수 ----
|
||||
for code in list(positions.keys()):
|
||||
h = hist.get(code)
|
||||
i = idx_map[code].get(day)
|
||||
if i is None:
|
||||
continue
|
||||
window = h[max(0, i - WINDOW):i + 1]
|
||||
ind = signals.compute_indicators(window)
|
||||
if ind is None:
|
||||
continue
|
||||
day_prices[code] = ind['price']
|
||||
dates_seen = [c['date'] for c in h[:i + 1]]
|
||||
flow = _flow_net_upto(flows.get(code), dates_seen)
|
||||
pos = positions[code]
|
||||
dec = signals.evaluate_holding(pos, ind, flow, None)
|
||||
pos.update(dec['position_update'])
|
||||
act = dec['action']
|
||||
if act in ('sell', 'scale_out'):
|
||||
fill = ind['price']
|
||||
total = pos['qty']
|
||||
qty = total if dec.get('sell_frac', 1.0) >= 1.0 else max(1, int(total * dec['sell_frac']))
|
||||
qty = min(qty, total)
|
||||
proceeds = qty * fill * (1 - comm - tax)
|
||||
cost = qty * pos['entry_price'] * (1 + comm)
|
||||
pnl = proceeds - cost
|
||||
cash += proceeds
|
||||
realized += pnl
|
||||
if qty >= total:
|
||||
positions.pop(code)
|
||||
closed += 1
|
||||
if pnl > 0:
|
||||
wins += 1
|
||||
else:
|
||||
pos['qty'] = total - qty
|
||||
if pnl > 0:
|
||||
gross_win += pnl
|
||||
else:
|
||||
gross_loss += -pnl
|
||||
elif act == 'add':
|
||||
fill = ind['price']
|
||||
tranche_val = pos.get('tranche_value') or (pos['entry_price'] * pos['qty'])
|
||||
qty = _qty_for(fill, tranche_val)
|
||||
cost = qty * fill * (1 + comm)
|
||||
if qty >= 1 and cost <= cash:
|
||||
new_qty = pos['qty'] + qty
|
||||
pos['entry_price'] = (pos['entry_price'] * pos['qty'] + fill * qty) / new_qty
|
||||
pos['qty'] = new_qty
|
||||
pos['tranches'] = pos.get('tranches', 1) + 1
|
||||
pos['last_add_price'] = fill
|
||||
cash -= cost
|
||||
nstop, ntarget = signals.compute_stop_target(pos['entry_price'], ind['atr'], ind['recent_low'])
|
||||
pos['stop'] = max(pos['stop'], nstop)
|
||||
pos['target'] = ntarget
|
||||
|
||||
# ---- 신규 매수 판단 (1차 트랜치) ----
|
||||
for code, h in hist.items():
|
||||
if code in positions or len(positions) >= config.MAX_POSITIONS:
|
||||
continue
|
||||
i = idx_map[code].get(day)
|
||||
if i is None:
|
||||
continue
|
||||
window = h[max(0, i - WINDOW):i + 1]
|
||||
ind = signals.compute_indicators(window)
|
||||
if ind is None:
|
||||
continue
|
||||
day_prices[code] = ind['price']
|
||||
if not signals.trend_ok(ind):
|
||||
continue
|
||||
dates_seen = [c['date'] for c in h[:i + 1]]
|
||||
flow = _flow_net_upto(flows.get(code), dates_seen)
|
||||
dec = signals.evaluate_candidate(code, code, [], ind, flow, None, True)
|
||||
if dec['action'] == 'buy':
|
||||
fill = dec['buy_price']
|
||||
eq = equity(day_prices)
|
||||
tranche_val = (eq / config.MAX_POSITIONS) / max(1, config.ENTRY_TRANCHES)
|
||||
qty = _qty_for(fill, tranche_val)
|
||||
cost = qty * fill * (1 + comm)
|
||||
if qty < 1 or cost > cash:
|
||||
continue
|
||||
stop, target = signals.compute_stop_target(fill, ind['atr'], ind['recent_low'])
|
||||
cash -= cost
|
||||
positions[code] = {'code': code, 'name': code, 'qty': qty, 'entry_price': fill,
|
||||
'stop': stop, 'target': target, 'peak': fill, 'trailing_on': False,
|
||||
'scaled_out': False, 'tranches': 1, 'tranche_value': tranche_val,
|
||||
'last_add_price': fill}
|
||||
|
||||
eq = equity(day_prices)
|
||||
if eq > peak_eq:
|
||||
peak_eq = eq
|
||||
dd = (peak_eq - eq) / peak_eq if peak_eq else 0
|
||||
if dd > max_dd:
|
||||
max_dd = dd
|
||||
|
||||
# 마지막 날 종가로 잔여 포지션 청산 평가 (미실현 포함 최종자산)
|
||||
final_prices = {}
|
||||
for code, p in positions.items():
|
||||
h = hist[code]
|
||||
final_prices[code] = h[-1]['close']
|
||||
final_eq = cash + sum(p['qty'] * final_prices[c] for c, p in positions.items())
|
||||
|
||||
n_days = len(all_dates)
|
||||
total_ret = (final_eq / capital - 1) * 100
|
||||
years = n_days / 252 if n_days else 0
|
||||
cagr = ((final_eq / capital) ** (1 / years) - 1) * 100 if years > 0 and final_eq > 0 else 0
|
||||
pf = (gross_win / gross_loss) if gross_loss > 0 else (math.inf if gross_win > 0 else 0)
|
||||
|
||||
d_from = all_dates[0] if all_dates else None
|
||||
d_to = all_dates[-1] if all_dates else None
|
||||
from . import benchmark
|
||||
bench = benchmark.compare(round(total_ret, 2), d_from, d_to) if d_from else {}
|
||||
|
||||
return {
|
||||
'final_equity': round(final_eq),
|
||||
'total_return_pct': round(total_ret, 2),
|
||||
'cagr_pct': round(cagr, 2),
|
||||
'mdd_pct': round(max_dd * 100, 2),
|
||||
'trades': closed,
|
||||
'open_positions': len(positions),
|
||||
'win_rate_pct': round(wins / closed * 100, 1) if closed else None,
|
||||
'profit_factor': round(pf, 2) if pf != math.inf else 'inf',
|
||||
'days': n_days,
|
||||
'from': d_from,
|
||||
'to': d_to,
|
||||
'flow_used': any(f is not None for f in flows.values()),
|
||||
'benchmark': bench,
|
||||
'alpha_pct': bench.get('KOSPI', {}).get('alpha'),
|
||||
}
|
||||
Reference in New Issue
Block a user