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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user