fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정

- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
This commit is contained in:
2026-10-10 12:17:49 +09:00
parent 5c4803b339
commit a264561576
230 changed files with 41160 additions and 2334 deletions

View File

@@ -31,11 +31,13 @@ from app.services.mean_reversion_strategy import (
calculate_atr,
calculate_bollinger,
calculate_rsi,
check_buy_signal,
check_buy_signal_backtest_v2,
check_exit_signal,
detect_market_regime,
is_reversal_candle,
ExitSignal,
)
from app.services.sizing_core import remaining_pct_after_partial_sell
logger = logging.getLogger(__name__)
@@ -55,6 +57,9 @@ class BarData:
volume: int
stock_code: str = ""
stock_name: str = ""
# True 면 DB 에 없는 날(거래정지·부분 수집일·상장폐지 후)을 전일 종가로 메운 가짜 봉.
# 체결·신호·지표 계산에서 제외한다 (기본 False = 기존 호출부와 호환).
is_filler: bool = False
@dataclass
@@ -113,7 +118,10 @@ class Position:
entry_price: float
quantity: int
highest_since_entry: float # 트레일링 스탑용 최고가
remaining_pct: float = 1.0 # 분할매도 잔여 비율
remaining_pct: float = 1.0 # 분할매도 잔여 비율 (원본 수량 대비 단계값)
original_quantity: int = 0 # 진입 시 원본 수량 (0 이면 quantity 로 간주)
entry_bar_idx: int = -1 # 진입 체결 봉 인덱스 (시간손절 보유 거래일 계산, -1=미상)
strategy: str = "MEAN_REVERSION" # 청산 분기용 ("MOMENTUM_BREAKOUT" 이면 모멘텀 전용 청산)
# ---------------------------------------------------------------------------
@@ -142,6 +150,13 @@ def _apply_sell_slippage(price: float, seed: Optional[int] = None) -> float:
return price * (1 - slip)
def _apply_sell_slippage_v2(price: float, seed: Optional[int] = None,
max_pct: float = SLIPPAGE_PCT) -> float:
"""장중 손절 체결용 매도 슬리피지 — 폭(max_pct)을 별도 파라미터(stop_slippage_pct)로 지정."""
rng = random.Random(seed)
return price * (1 - rng.uniform(0, abs(max_pct)))
def _calc_buy_cost(price: float, quantity: int) -> Tuple[float, float]:
"""매수 비용 계산 -> (총비용, 수수료)"""
amount = price * quantity
@@ -330,6 +345,181 @@ class BacktestStrategy(ABC):
"""
...
def intraday_exits_v2(
self,
bar_idx: int,
bars: Dict[str, List[BarData]],
positions: Dict[str, Position],
params: Dict[str, Any],
) -> List[Dict[str, Any]]:
"""장중 체결형 청산(손절). 엔진이 bar_idx 봉 시가 체결 직후 호출한다.
반환: [{"stock_code", "base_price"(슬리피지 전 체결기준가), "reason"}] — 전량 청산.
기본은 없음(기존 전략 호환)."""
return []
def _bt_param_or_default_v2(params: Dict[str, Any], key: str, default: float,
lo: float, hi: float) -> float:
"""백테스트 파라미터 검증: 유한하고 [lo, hi] 안이면 params 값, 아니면 default(+warning)."""
val = params.get(key)
if val is None:
return float(default)
try:
f = float(val)
except (TypeError, ValueError):
f = float("nan")
if f != f or f in (float("inf"), float("-inf")) or not (lo <= abs(f) <= hi):
logger.warning("[KRBacktest] 파라미터 %s=%r 비정상 — 기본값 %s 사용", key, val, default)
return float(default)
return f
def _stop_price_v2(params: Dict[str, Any], entry_price: float, atr: float,
ref_close: float) -> Tuple[float, float, float, float]:
"""손절가 단일 계산(종가 판정·장중 판정 공용) -> (stop, atr_stop, pct_stop, stop_loss_pct).
stop = max(진입-배수×ATR, 진입×(1+손절률)) — 가격 비교이므로 max 가 타이트한 쪽(§5.2).
고변동(ATR/ref_close > 3%)이면 STOP_LOSS_HIGH_VOL. stop_loss_pct 는 `-abs()` 부호 가드.
"""
atr_mult = _bt_param_or_default_v2(
params, "atr_stop_multiplier", settings.ATR_STOP_MULTIPLIER,
float(settings.SWING_ATR_STOP_MULT_MIN), float(settings.SWING_ATR_STOP_MULT_MAX))
atr_pct = (float(atr) / ref_close * 100) if (atr and ref_close > 0) else 0.0
if atr_pct > 3.0:
sl_raw = _bt_param_or_default_v2(
params, "stop_loss_high_vol", settings.STOP_LOSS_HIGH_VOL,
float(settings.SWING_STOP_LOSS_PCT_MIN), float(settings.SWING_STOP_LOSS_PCT_MAX))
else:
sl_raw = _bt_param_or_default_v2(
params, "stop_loss_pct", settings.STOP_LOSS_PERCENT,
float(settings.SWING_STOP_LOSS_PCT_MIN), float(settings.SWING_STOP_LOSS_PCT_MAX))
stop_loss_pct = -abs(sl_raw)
atr_stop = entry_price - atr_mult * atr
pct_stop = entry_price * (1 + stop_loss_pct / 100)
return max(atr_stop, pct_stop), atr_stop, pct_stop, stop_loss_pct
def intraday_stop_fill_v2(params: Dict[str, Any], entry_price: float, atr: float,
prev_close: float, bar_open: float, bar_low: float
) -> Optional[Tuple[float, float]]:
"""장중 손절 판정(순수) -> (체결기준가, 손절가) 또는 None.
실전은 60초 주기 실시세로 close<=stop 을 보고 시장가 매도하므로, 일봉에서는
bar.low <= stop 이면 손절로 본다. 체결 기준가는 min(stop, open) — 갭다운이면 시가.
ATR·고변동 판정은 전일까지의 값(prev_close)만 쓴다(당일 정보 룩어헤드 금지).
"""
if entry_price <= 0 or atr is None or atr <= 0:
return None
stop_price, _, _, _ = _stop_price_v2(params, entry_price, atr, prev_close)
if bar_low <= stop_price:
return min(stop_price, float(bar_open)), stop_price
return None
def check_time_stop_backtest_v2(params: Dict[str, Any], holding_bars: int,
profit_pct: float) -> Optional[ExitSignal]:
"""스윙 시간손절(순수): 보유 봉수(=거래일) >= N 이고 수익률 < 최소수익이면 전량.
기간·최소수익은 params(swing_time_stop_*) → settings.SWING_TIME_STOP_* 순.
SWING_TIME_STOP_ENABLED 가 꺼져 있으면(params 로도 override 가능) None.
"""
enabled = params.get("swing_time_stop_enabled", settings.SWING_TIME_STOP_ENABLED)
if not enabled:
return None
max_days = int(params.get("swing_time_stop_days", settings.SWING_TIME_STOP_DAYS))
min_profit = float(params.get("swing_time_stop_min_profit_pct", settings.SWING_TIME_STOP_MIN_PROFIT_PCT))
if holding_bars >= max_days and profit_pct < min_profit:
return ExitSignal(
action="SELL_ALL",
reason=f"시간손절 ({holding_bars}거래일, {profit_pct:+.1f}% < +{min_profit}%)",
sell_pct=1.0,
)
return None
def check_momentum_exit_backtest_v2(params: Dict[str, Any], close: float, entry_price: float,
atr: float, remaining_pct: float,
trailing_high: Optional[float]) -> Optional[ExitSignal]:
"""모멘텀 돌파 전용 청산(결정론). swing/exit_service._check_momentum_exit 와 같은 구조:
손절 → 트레일링(수익 중) → +10% 30% → +20% 30%. 라이브 적응형·DB 를 읽지 않는다."""
if entry_price <= 0:
return None
profit_pct = (close - entry_price) / entry_price * 100
stop_price, _, _, _ = _stop_price_v2(params, entry_price, atr, close)
if close <= stop_price:
return ExitSignal(action="SELL_ALL",
reason=f"모멘텀 손절 ({profit_pct:.1f}%, stop={stop_price:.0f})", sell_pct=1.0)
if trailing_high and trailing_high > entry_price:
trail_mult = float(params.get("atr_trailing_multiplier", settings.ATR_TRAILING_MULTIPLIER))
trail_stop = trailing_high - trail_mult * atr
if close <= trail_stop and profit_pct > 0:
return ExitSignal(action="SELL_ALL",
reason=f"모멘텀 트레일링 (고점={trailing_high:.0f}, 트레일={trail_stop:.0f})",
sell_pct=1.0)
if profit_pct >= 10.0 and remaining_pct > 0.75:
return ExitSignal(action="SELL_PARTIAL", reason=f"모멘텀 1차 익절 +{profit_pct:.1f}% → 30% 매도",
sell_pct=0.30)
if profit_pct >= 20.0 and 0.45 < remaining_pct <= 0.75:
return ExitSignal(action="SELL_PARTIAL", reason=f"모멘텀 2차 익절 +{profit_pct:.1f}% → 30% 매도",
sell_pct=0.30)
return None
def check_exit_signal_backtest_v2(
params: Dict[str, Any],
rsi: float,
close: float,
bb_upper: float,
bb_middle: float,
entry_price: float,
atr: float,
remaining_pct: float,
trailing_high: Optional[float] = None,
) -> Optional[ExitSignal]:
"""백테스트 전용 청산 판정 — 라이브 적응형 모드·DB(strategy_cfg)를 읽지 않는다.
손절·익절 임계는 전부 `params`(기본 settings) 에서 명시 주입된다. 같은 입력이면
실행 시각과 무관하게 같은 결과(결정론). 로직 구조는 mean_reversion_strategy.check_exit_signal
과 동일(손절 → 1차 익절 → 전량/트레일링). 손절률은 `-abs()` 부호 가드를 적용한다.
"""
stop_price, atr_stop, pct_stop, stop_loss_pct = _stop_price_v2(params, entry_price, atr, close)
if close <= stop_price:
return ExitSignal(
action="SELL_ALL",
reason=f"손절 (close={close:.0f} <= stop={stop_price:.0f}, ATR기준={atr_stop:.0f}, {stop_loss_pct:.1f}%기준={pct_stop:.0f})",
sell_pct=1.0,
)
rsi_partial = float(params.get("rsi_exit_threshold", settings.MEAN_REVERSION_RSI_EXIT_THRESHOLD))
rsi_full = float(params.get("rsi_full_exit", settings.MEAN_REVERSION_RSI_FULL_EXIT))
trail_mult = float(params.get("atr_trailing_multiplier", 1.5))
threshold = 0.50
profit_pct = ((close - entry_price) / entry_price * 100) if entry_price > 0 else 0.0
if remaining_pct > threshold and profit_pct >= 0.5:
reasons = []
if rsi >= rsi_partial:
reasons.append(f"RSI({rsi:.1f})>={rsi_partial:.0f}")
if close > bb_middle:
reasons.append(f"BB중앙({bb_middle:.0f})상회")
if close >= entry_price + 2.0 * atr:
reasons.append(f"2×ATR({atr:.0f})수익")
if reasons:
return ExitSignal(action="SELL_PARTIAL",
reason=f"1차 익절(50%): {' OR '.join(reasons)}", sell_pct=0.50)
full = []
if rsi >= rsi_full:
full.append(f"RSI({rsi:.1f})>={rsi_full}")
if close >= bb_upper * settings.MEAN_REVERSION_BB_UPPER_THRESHOLD:
full.append(f"BB상단({bb_upper:.0f})도달")
if remaining_pct <= threshold + 1e-9 and trailing_high is not None and trailing_high > entry_price:
trail_stop = trailing_high - trail_mult * atr
if close < trail_stop:
full.append(f"트레일링(close={close:.0f}<{trail_stop:.0f})")
if full:
return ExitSignal(action="SELL_ALL", reason=f"전량청산: {' OR '.join(full)}", sell_pct=1.0)
return None
# ---------------------------------------------------------------------------
# 내장 전략: MeanReversionBacktest (RSI + BB 평균회귀)
@@ -338,7 +528,7 @@ class BacktestStrategy(ABC):
class MeanReversionBacktest(BacktestStrategy):
"""CLAUDE.md 섹션 6 RSI+BB 평균회귀 전략 백테스트 버전
기존 mean_reversion_strategy.py의 check_buy_signal / check_exit_signal을
기존 mean_reversion_strategy.py의 check_buy_signal_backtest_v2(결정론 진입) / check_exit_signal_backtest_v2 를
그대로 재활용합니다. 시장 국면(regime)은 코스피200 데이터가 없으므로
전체 종목 평균으로 대체하거나, 파라미터로 고정 지정 가능합니다.
"""
@@ -357,11 +547,12 @@ class MeanReversionBacktest(BacktestStrategy):
"max_positions": settings.MAX_HOLDINGS_COUNT,
"position_size_pct": settings.MAX_POSITION_SIZE,
"stop_loss_pct": settings.STOP_LOSS_PERCENT,
"stop_loss_high_vol": settings.STOP_LOSS_HIGH_VOL,
"atr_stop_multiplier": settings.ATR_STOP_MULTIPLIER,
"atr_trailing_multiplier": settings.ATR_TRAILING_MULTIPLIER,
"rsi_exit_threshold": settings.MEAN_REVERSION_RSI_EXIT_THRESHOLD,
"rsi_full_exit": settings.MEAN_REVERSION_RSI_FULL_EXIT,
"regime": "BULL", # "BULL", "BEAR", "AUTO"
"regime": "BULL", # "BULL", "BEAR", "SIDEWAYS" 고정만 지원 (AUTO 는 ValueError)
"market": "KOSPI", # 세금 계산용
"min_price": settings.MIN_PRICE_KRW,
"min_data_bars": 60, # RSI/BB 계산에 필요한 최소 봉 수
@@ -386,9 +577,13 @@ class MeanReversionBacktest(BacktestStrategy):
continue
current_bar = stock_bars[bar_idx]
closes = [b.close_price for b in stock_bars[: bar_idx + 1]]
highs = [b.high_price for b in stock_bars[: bar_idx + 1]]
lows = [b.low_price for b in stock_bars[: bar_idx + 1]]
if current_bar.is_filler:
# 결측일(정지·미수집): 손절/고점 갱신 판정 보류, 다음 실제 봉에서 판정
continue
real_bars = _real_bars_upto(stock_bars, bar_idx)
closes = [b.close_price for b in real_bars]
highs = [b.high_price for b in real_bars]
lows = [b.low_price for b in real_bars]
if len(closes) < min_bars:
continue
@@ -405,24 +600,44 @@ class MeanReversionBacktest(BacktestStrategy):
# 최고가 업데이트
pos.highest_since_entry = max(pos.highest_since_entry, current_bar.high_price)
exit_signal = check_exit_signal(
regime=self._get_regime(params),
rsi=rsi,
close=float(current_bar.close_price),
bb_upper=bb_upper,
bb_middle=bb_middle,
bb_lower=bb_lower,
entry_price=pos.entry_price,
atr=atr,
lowest_since_entry=float(current_bar.low_price),
remaining_pct=pos.remaining_pct,
trailing_high=pos.highest_since_entry,
)
is_momentum = str(getattr(pos, "strategy", "")).upper() == "MOMENTUM_BREAKOUT"
if is_momentum:
exit_signal = check_momentum_exit_backtest_v2(
params, float(current_bar.close_price), pos.entry_price, atr,
pos.remaining_pct, pos.highest_since_entry,
)
else:
exit_signal = check_exit_signal_backtest_v2(
params=params,
rsi=rsi,
close=float(current_bar.close_price),
bb_upper=bb_upper,
bb_middle=bb_middle,
entry_price=pos.entry_price,
atr=atr,
remaining_pct=pos.remaining_pct,
trailing_high=pos.highest_since_entry,
)
# 시간손절(실전 swing/exit_service 와 같은 우선순위): 모멘텀 제외, 전량 신호가 이미
# 있으면 유지, 없거나 부분매도면 시간손절(전량)이 덮어쓴다. 보유일=봉 인덱스 차.
if (not is_momentum and pos.entry_bar_idx >= 0
and (exit_signal is None or exit_signal.sell_pct < 1.0)):
ts_profit = ((float(current_bar.close_price) - pos.entry_price) / pos.entry_price * 100
if pos.entry_price > 0 else 0.0)
ts_signal = check_time_stop_backtest_v2(params, bar_idx - pos.entry_bar_idx, ts_profit)
if ts_signal is not None:
exit_signal = ts_signal
if exit_signal:
sell_qty = int(pos.quantity * exit_signal.sell_pct)
if sell_qty <= 0:
# 실전 규약: sell_pct 는 원본 수량 대비, 잔량 ≤3주면 전량
orig_qty = pos.original_quantity or pos.quantity
if exit_signal.sell_pct >= 1.0 or pos.quantity <= 3:
sell_qty = pos.quantity
else:
sell_qty = min(int(orig_qty * exit_signal.sell_pct), pos.quantity)
if sell_qty <= 0:
sell_qty = pos.quantity
orders.append({
"action": "SELL",
@@ -447,11 +662,14 @@ class MeanReversionBacktest(BacktestStrategy):
continue
current_bar = stock_bars[bar_idx]
closes = [b.close_price for b in stock_bars[: bar_idx + 1]]
highs = [b.high_price for b in stock_bars[: bar_idx + 1]]
lows = [b.low_price for b in stock_bars[: bar_idx + 1]]
opens = [b.open_price for b in stock_bars[: bar_idx + 1]]
volumes = [b.volume for b in stock_bars[: bar_idx + 1]]
if current_bar.is_filler:
continue # 결측일에는 신규 진입 신호를 만들지 않는다
real_bars = _real_bars_upto(stock_bars, bar_idx)
closes = [b.close_price for b in real_bars]
highs = [b.high_price for b in real_bars]
lows = [b.low_price for b in real_bars]
opens = [b.open_price for b in real_bars]
volumes = [b.volume for b in real_bars]
if len(closes) < min_bars:
continue
@@ -491,7 +709,10 @@ class MeanReversionBacktest(BacktestStrategy):
ma20_val = sum(closes[-20:]) / 20 if len(closes) >= 20 else None
ma60_val = sum(closes[-60:]) / 60 if len(closes) >= 60 else None
signal = check_buy_signal(
# [하드닝 R2 2026-10-09] 라이브 적응형 모드·strategy_cfg 를 읽지 않는 결정론 진입 판정.
# 진입 임계(rsi_entry·bear_rsi_entry·bb_pct_b·bear_volume_mult)는 params 로 주입한다.
signal = check_buy_signal_backtest_v2(
params,
regime=self._get_regime(params),
rsi=rsi,
close=float(current_bar.close_price),
@@ -530,9 +751,56 @@ class MeanReversionBacktest(BacktestStrategy):
return orders
def intraday_exits_v2(
self,
bar_idx: int,
bars: Dict[str, List[BarData]],
positions: Dict[str, Position],
params: Dict[str, Any],
) -> List[Dict[str, Any]]:
"""장중 저가 손절: bar.low <= stop 이면 min(stop, open) 기준가로 전량 청산.
ATR 은 전일까지의 실제 봉으로만 계산(룩어헤드 없음). params['intraday_stop_enabled']=False
로 끄면 종전(종가 판정) 동작 — A-B 비교용."""
if not params.get("intraday_stop_enabled", True) or bar_idx < 1:
return []
min_bars = params.get("min_data_bars", 60)
out: List[Dict[str, Any]] = []
for code, pos in list(positions.items()):
stock_bars = bars.get(code, [])
if bar_idx >= len(stock_bars):
continue
cur = stock_bars[bar_idx]
if cur.is_filler:
continue
prev = _real_bars_upto(stock_bars, bar_idx - 1)
if len(prev) < min_bars:
continue
atr = calculate_atr([b.high_price for b in prev], [b.low_price for b in prev],
[b.close_price for b in prev], params.get("atr_period", 14))
if atr is None:
continue
hit = intraday_stop_fill_v2(params, pos.entry_price, atr, float(prev[-1].close_price),
float(cur.open_price), float(cur.low_price))
if hit is None:
continue
base, stop = hit
out.append({
"stock_code": code,
"base_price": base,
"reason": f"장중손절 (low={cur.low_price} <= stop={stop:.0f}, 체결기준={base:.0f})",
})
return out
@staticmethod
def _get_regime(params: Dict[str, Any]) -> MarketRegime:
regime_str = params.get("regime", "BULL").upper()
regime_str = str(params.get("regime", "BULL")).upper()
if regime_str == "AUTO":
# 지수(코스피200/KODEX200) 데이터가 DB 에 없어 일자별 국면을 재현할 수 없다.
# 조용히 BULL 로 돌리면 SIDEWAYS/BEAR 게이트가 빠진 결과가 실전처럼 보인다.
raise ValueError("regime='AUTO' 는 미지원(지수 데이터 없음) — BULL/BEAR/SIDEWAYS 중 고정 지정")
if regime_str not in ("BULL", "BEAR", "SIDEWAYS"):
raise ValueError(f"알 수 없는 regime: {regime_str}")
if regime_str == "BEAR":
return MarketRegime.BEAR
if regime_str == "SIDEWAYS":
@@ -575,6 +843,50 @@ def get_strategy(name: str) -> BacktestStrategy:
# 데이터 로더 (async — DB 조회)
# ---------------------------------------------------------------------------
PRICE_EVENT_TOL = 0.25 # collect_krx_data.PRICE_EVENT_TOL 과 동일 정의
PRICE_EVENT_MAX_GAP_DAYS = 4 # 이보다 긴 누락 구간은 이벤트가 아니라 갭 (수집기와 동일)
def back_adjust_price_events_v2(
bars: List[BarData],
change_rates: List[Optional[float]],
tol: float = PRICE_EVENT_TOL,
max_gap_days: int = PRICE_EVENT_MAX_GAP_DAYS,
) -> int:
"""원주가 불연속을 감지해 이벤트 이전 봉의 OHLC/거래량을 제자리에서 역보정한다. 순수 함수.
change_rates[i] 는 bars[i] 의 거래소 등락률(소수, 분할 반영된 값). 실제 종가비와 tol 초과
괴리면 이벤트: 이전 봉들을 k = close / (prev_close*(1+cr)) 배 하고 거래량은 k 로 나눈다.
change_rate 가 없으면 판정 불가라 보정하지 않는다. 반환: 보정한 이벤트 수.
"""
n = 0
for i in range(len(bars) - 1, 0, -1):
cr = change_rates[i] if i < len(change_rates) else None
if cr is None:
continue
cur, prev = bars[i], bars[i - 1]
if not cur.close_price or not prev.close_price or cur.close_price <= 0 or prev.close_price <= 0:
continue
if (cur.trade_date - prev.trade_date).days > max_gap_days:
continue
if abs(cur.close_price / prev.close_price - 1.0 - cr) <= tol:
continue
denom = prev.close_price * (1.0 + cr)
if cr <= -0.99 or denom <= 0:
continue
k = cur.close_price / denom
if k <= 0:
continue
for b in bars[:i]:
b.open_price = int(round(b.open_price * k))
b.high_price = int(round(b.high_price * k))
b.low_price = int(round(b.low_price * k))
b.close_price = int(round(b.close_price * k))
b.volume = int(round(b.volume / k))
n += 1
return n
async def load_daily_bars(
stock_codes: List[str],
start_date: date,
@@ -591,6 +903,8 @@ async def load_daily_bars(
from app.core.database import AsyncSessionLocal
from app.models.models import StockDailyPrice
# 주의: stock_daily_prices 는 수정주가가 아니다. 분할·병합·권리락 불연속은
# change_rate(거래소 등락률)와 종가비 괴리로 감지해 과거 봉을 역보정한다.
bars_by_code: Dict[str, List[BarData]] = {}
all_dates_set: set = set()
@@ -613,7 +927,11 @@ async def load_daily_bars(
continue
bar_list: List[BarData] = []
change_rates: List[Optional[float]] = []
for row in rows:
change_rates.append(
float(row.change_rate) if row.change_rate is not None else None
)
bar_list.append(BarData(
trade_date=row.trade_date,
open_price=row.open_price or 0,
@@ -626,12 +944,22 @@ async def load_daily_bars(
))
all_dates_set.add(row.trade_date)
n_events = back_adjust_price_events_v2(bar_list, change_rates)
if n_events:
logger.warning(
"종목 %s: 원주가 불연속 %d건 역보정 (분할/병합/권리락 추정)", code, n_events,
)
bars_by_code[code] = bar_list
all_dates = sorted(all_dates_set)
return bars_by_code, all_dates
def _real_bars_upto(stock_bars: List[BarData], bar_idx: int) -> List[BarData]:
"""bar_idx 까지의 실제 봉만 (가짜 채움 봉 제외) — 지표 계산용"""
return [b for b in stock_bars[: bar_idx + 1] if not getattr(b, "is_filler", False)]
def _align_bars_to_dates(
bars_by_code: Dict[str, List[BarData]],
all_dates: List[date],
@@ -664,6 +992,7 @@ def _align_bars_to_dates(
volume=0,
stock_code=code,
stock_name=last_bar.stock_name,
is_filler=True,
)
result.append(filler)
# 첫 날짜 이전 데이터가 없으면 건너뜀 (나중에 min_bars로 필터)
@@ -730,6 +1059,8 @@ class KrBacktestEngine:
if params:
effective_params.update(params)
effective_params["market"] = market
# 데이터 로드 전에 거부 (AUTO 가 조용히 BULL 로 처리되던 결함)
MeanReversionBacktest._get_regime(effective_params)
# 1. 데이터 로드
logger.info(
@@ -810,6 +1141,7 @@ class KrBacktestEngine:
total_equity = cash + position_value
# -- 1단계: 전일 신호를 오늘 open + 슬리피지로 체결 --
carry_orders: List[Dict[str, Any]] = []
for order in pending_orders:
action = order["action"]
code = order["stock_code"]
@@ -819,6 +1151,12 @@ class KrBacktestEngine:
stock_bars = bars.get(code, [])
if bar_idx >= len(stock_bars):
continue
if getattr(stock_bars[bar_idx], "is_filler", False):
# 가짜 봉(정지·미수집일)에서는 체결 불가 -> 매도만 다음 실제 봉으로 이월.
# 매수는 낡은 신호이므로 폐기. 이후 실제 봉이 없으면(상장폐지 등) 미체결 유지.
if order["action"] == "SELL":
carry_orders.append(order)
continue
next_open = float(stock_bars[bar_idx].open_price)
# 슬리피지용 시드
@@ -861,6 +1199,8 @@ class KrBacktestEngine:
entry_price=exec_price,
quantity=quantity,
highest_since_entry=exec_price,
original_quantity=quantity,
entry_bar_idx=bar_idx,
)
elif action == "SELL":
@@ -905,9 +1245,44 @@ class KrBacktestEngine:
entry_trades.pop(code, None)
else:
pos.quantity = remaining_qty
pos.remaining_pct = remaining_qty / (pos.quantity + sell_qty)
pos.remaining_pct = remaining_pct_after_partial_sell(
pos.remaining_pct,
float(order.get("sell_pct", 0.0) or 0.0),
remaining_qty,
pos.original_quantity or (remaining_qty + sell_qty),
)
pending_orders.clear()
pending_orders = carry_orders
# -- 1.5단계: 장중 손절(저가 터치) — 오늘 봉 안에서 체결 (실전 60초 감시 근사) --
for ix in strategy.intraday_exits_v2(bar_idx, bars, positions, params):
code = ix["stock_code"]
pos = positions.get(code)
if pos is None:
continue
if seed_counter is not None:
seed_counter += 1
slip_seed = seed_counter
else:
slip_seed = None
base_price = float(ix["base_price"])
exec_price = _apply_sell_slippage_v2(
base_price, slip_seed, float(params.get("stop_slippage_pct", SLIPPAGE_PCT)))
sell_qty = pos.quantity
proceeds, commission, tax = _calc_sell_proceeds(exec_price, sell_qty, market)
cash += proceeds
entry_cost = pos.entry_price * sell_qty * (1 + BUY_COMMISSION_RATE)
pnl = proceeds - entry_cost
pnl_pct = (pnl / entry_cost) * 100 if entry_cost > 0 else 0.0
all_trades.append(BacktestTrade(
stock_code=code, side="SELL", entry_date=pos.entry_date, exit_date=current_date,
entry_price=pos.entry_price, exit_price=exec_price, quantity=sell_qty,
commission=commission, tax=tax,
slippage=abs(base_price - exec_price) * sell_qty,
pnl=round(pnl, 2), pnl_pct=round(pnl_pct, 4), reason=ix.get("reason", ""),
))
del positions[code]
entry_trades.pop(code, None)
# -- 2단계: 오늘 close 기준으로 신호 생성 (체결은 내일 open) --
is_last_bar = (bar_idx == len(all_dates) - 1)
@@ -928,7 +1303,7 @@ class KrBacktestEngine:
total_equity=total_equity,
params=params,
)
pending_orders = list(orders)
pending_orders = carry_orders + list(orders)
# 일말 평가
position_value = 0.0
@@ -953,6 +1328,15 @@ class KrBacktestEngine:
"drawdown_pct": round(dd, 4),
})
# 종료 시점에 마지막 봉이 가짜 봉인 보유 종목: 정지/상장폐지 가능성 -> 청산 불가 상태로 평가됨
for code in positions:
sb = bars.get(code, [])
if sb and getattr(sb[-1], "is_filler", False):
logger.warning(
"백테스트 종료 시점 %s 은 실제 시세가 끊긴 채(정지/상장폐지 추정) 마지막 실제 종가로 평가됨 — 손실이 과소평가될 수 있음",
code,
)
# 성과 지표 계산
metrics = _compute_metrics(
equity_curve=equity_values,