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

@@ -30,7 +30,7 @@ RSI + 볼린저 밴드 평균회귀 전략 (CLAUDE.md 섹션 7.1-7.5)
MEAN_REVERSION_MULTIPLIER_WEAK
"""
import logging
from typing import Optional, Dict, List, Tuple
from typing import Any, Callable, Dict, List, Optional, Tuple
from dataclasses import dataclass
from enum import Enum
@@ -302,6 +302,13 @@ def check_bear_buy_signal_v2(
가중 점수 합산으로 완화한다. `check_buy_signal`의 BEAR 분기에서
`MEAN_REVERSION_BEAR_V2_ENABLED=True` 일 때만 호출된다(기본 False).
⚠️ **백테스트 전용 (2026-10-09 하드닝 R2 확인)**: `check_buy_signal` 의 호출부는
`kr_backtest_engine`·`run_backtest` 뿐이다. 실전 1차 진입 게이트는
`api/endpoints/recommendations.is_mean_reversion_candidate` 이고 그 BEAR 분기는
(RSI ≤ 임계 + %B 동적 상한)만 보며 이 함수·거래량·반전캔들을 참조하지 않는다.
따라서 이 플래그를 True 로 바꿔도 **실전 BEAR 진입은 달라지지 않는다.**
실전 반영은 StockAnalysis 에 거래량비·반전캔들 입력을 갖춘 뒤 별도 `_v2` 배선이 필요하다.
⚠️ 백테스트 미검증 — `True` 전환 금지.
DB(NAS) 중단으로 백테스트를 돌릴 수 없고, 기존 백테스트 산출물은 실전과 다른 지표
(SMA RSI vs Wilder, 발산한 ADX)로 생성된 것이 확인되어 근거로 쓸 수 없다.
@@ -340,9 +347,48 @@ def check_bear_buy_signal_v2(
_t = strategy_cfg.get("MEAN_REVERSION_BEAR_RSI_THRESHOLD")
bear_rsi_threshold = float(_t) if _t is not None else settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD
return _check_bear_buy_signal_v2_impl(
rsi=rsi,
close=close,
bb_upper=bb_upper,
bb_lower=bb_lower,
avg_volume_20d=avg_volume_20d,
current_volume=current_volume,
is_reversal=is_reversal,
rsi_prev=rsi_prev,
bear_rsi_threshold=float(bear_rsi_threshold),
cfg_get=_bear_v2_cfg,
bear_vol_mult_fn=_live_bear_volume_mult_v2,
)
def _live_bear_volume_mult_v2() -> float:
"""BEAR 거래량 배수 — strategy_cfg(DB) > settings(config.py)."""
from app.core.config import settings
_bvm = strategy_cfg.get("MEAN_REVERSION_BEAR_VOLUME_MULT")
return float(_bvm) if _bvm is not None else float(settings.MEAN_REVERSION_BEAR_VOLUME_MULT)
def _check_bear_buy_signal_v2_impl(
rsi: float,
close: float,
bb_upper: float,
bb_lower: float,
avg_volume_20d: float,
current_volume: int,
is_reversal: bool,
rsi_prev: Optional[float],
bear_rsi_threshold: float,
cfg_get: Callable[[str], float],
bear_vol_mult_fn: Callable[[], float],
) -> Optional[Dict]:
"""BEAR v2 점수제 본체 — 파라미터 출처를 호출부가 주입한다(라이브: strategy_cfg/settings,
백테스트: params/settings). 이 함수 자체는 strategy_cfg·adaptive 를 읽지 않는다."""
from app.core.config import settings
# ── 필수 ①: RSI 상한 ────────────────────────────────────────────────────
rsi_relax = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_RSI_RELAX")
rsi_hard_cap = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_RSI_HARD_CAP")
rsi_relax = cfg_get("MEAN_REVERSION_BEAR_V2_RSI_RELAX")
rsi_hard_cap = cfg_get("MEAN_REVERSION_BEAR_V2_RSI_HARD_CAP")
rsi_upper = min(float(bear_rsi_threshold) + rsi_relax, rsi_hard_cap)
if not (rsi < rsi_upper):
return None
@@ -374,20 +420,19 @@ def check_bear_buy_signal_v2(
pct_b_max = 0.20
# ── 필수 ③: %B 하드 상한 (BB 중심선 위 매수 금지) ───────────────────────
pct_b_hard_cap = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_PCT_B_HARD_CAP")
pct_b_hard_cap = cfg_get("MEAN_REVERSION_BEAR_V2_PCT_B_HARD_CAP")
if pct_b > pct_b_hard_cap:
return None
w_pct_b = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_W_PCT_B")
w_reversal = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_W_REVERSAL")
w_volume = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_W_VOLUME")
w_depth = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_W_RSI_DEPTH")
pct_b_relax_mult = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_PCT_B_RELAX_MULT")
vol_mid_mult = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_VOLUME_MID_MULT")
min_score = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_MIN_SCORE")
w_pct_b = cfg_get("MEAN_REVERSION_BEAR_V2_W_PCT_B")
w_reversal = cfg_get("MEAN_REVERSION_BEAR_V2_W_REVERSAL")
w_volume = cfg_get("MEAN_REVERSION_BEAR_V2_W_VOLUME")
w_depth = cfg_get("MEAN_REVERSION_BEAR_V2_W_RSI_DEPTH")
pct_b_relax_mult = cfg_get("MEAN_REVERSION_BEAR_V2_PCT_B_RELAX_MULT")
vol_mid_mult = cfg_get("MEAN_REVERSION_BEAR_V2_VOLUME_MID_MULT")
min_score = cfg_get("MEAN_REVERSION_BEAR_V2_MIN_SCORE")
_bvm = strategy_cfg.get("MEAN_REVERSION_BEAR_VOLUME_MULT")
bear_vol_mult = float(_bvm) if _bvm is not None else settings.MEAN_REVERSION_BEAR_VOLUME_MULT
bear_vol_mult = float(bear_vol_mult_fn())
# ── 점수 합산 ───────────────────────────────────────────────────────────
score_pct_b = (
@@ -430,7 +475,7 @@ def check_bear_buy_signal_v2(
"split_plan": [0.30, 0.30, 0.40], # 3회 분할 매수 (기존 BEAR 와 동일)
"description": (
f"[BEARv2 score {total:.2f}≥{min_score:.2f}] RSI({rsi:.1f})<{rsi_upper:.1f} "
f"+ %%B({pct_b:.2f}/{pct_b_max:.2f}) + 거래량({vol_ratio:.2f}×) "
f"+ %B({pct_b:.2f}/{pct_b_max:.2f}) + 거래량({vol_ratio:.2f}×) "
f"+ 반전({'Y' if is_reversal else 'N'}) (하락장 과매도, 점수제)"
),
"bear_v2_score": round(total, 4),
@@ -462,15 +507,241 @@ def check_buy_signal(
[2026-03-15 CLAUDE.md 정합성 수정]
- SIDEWAYS 국면: 진입 차단 (CLAUDE.md 6.1)
- BULL: RSI < 40, BB하단 -3%~+1.5%, 거래량 >= 20일평균
- BEAR: RSI < 30, 종가 <= BB하단, 되돌림 캔들, 거래량 > 1.5×20일평균
- BULL: RSI < MEAN_REVERSION_RSI_THRESHOLD(실효값) + %B 동적 임계, 거래량 >= 20일평균
- BEAR: RSI < MEAN_REVERSION_BEAR_RSI_THRESHOLD(실효값) + %B 동적 임계 + 되돌림 캔들
+ 거래량 > MEAN_REVERSION_BEAR_VOLUME_MULT×20일평균
- RSI 반등 확인: rsi_prev 제공 시, RSI가 반등 중일 때만 진입 허용
⚠️ 임계는 실행 시각의 라이브 적응형 모드·strategy_cfg(DB)를 읽는다(§0 우선순위).
**백테스트는 이 함수를 쓰지 말고 `check_buy_signal_backtest_v2(params, ...)` 를 쓸 것**
(2026-10-09 하드닝 R2 — 같은 입력이 실행 시각마다 다른 결과를 내던 비결정성 제거).
[안전형 개선①] own_trend 게이트 (검증 완료: Sharpe 0.27→0.57)
- ma20/ma60 제공 시: 종목 자체 MA20 < MA60*0.97 (하락추세)이면 진입 차단
(물타기 방지 — 시장 국면과 무관하게 개별 종목이 자체 하락추세면 평균회귀 매수 금지)
- ma20/ma60 미제공(기본값 None): 게이트 미적용 — 기존 호출부/테스트 하위호환 유지
"""
return _check_buy_signal_impl(
regime=regime,
rsi=rsi,
close=close,
bb_upper=bb_upper,
bb_middle=bb_middle,
bb_lower=bb_lower,
avg_volume_20d=avg_volume_20d,
current_volume=current_volume,
prev_volume=prev_volume,
is_reversal=is_reversal,
rsi_prev=rsi_prev,
ma20=ma20,
ma60=ma60,
bull_rsi_fn=_live_bull_rsi_threshold_v2,
bb_scale_fn=_live_bb_pct_b_scale_v2,
bear_rsi_fn=_live_bear_rsi_threshold_v2,
bear_vol_mult_fn=_live_bear_volume_mult_v2,
bear_v2_enabled_fn=_bear_v2_enabled,
bear_v2_cfg_get=_bear_v2_cfg,
)
# ─────────────────────────────────────────────────────────────────────────────
# 진입 파라미터 리졸버 — 라이브(adaptive > strategy_cfg > settings)
# ─────────────────────────────────────────────────────────────────────────────
_BB_PCT_B_NORMAL_REF = 0.30 # 적응형 bb_pct_b 의 Normal 기준값 (스케일 = bb_pct_b / 0.30)
_BB_PCT_B_SCALE_CAP = 1.3 # S1 fix: BB 중심선(pct_b=0.5) 위 진입 방지 — 스케일 상한
def _bb_pct_b_to_scale_v2(bb_pct_b: float) -> float:
"""적응형 bb_pct_b → %B 임계 스케일 (Normal 0.30 = 1.0, 상한 1.3). 라이브·백테스트 공용."""
_scale = bb_pct_b / _BB_PCT_B_NORMAL_REF if bb_pct_b > 0 else 1.0
return min(_scale, _BB_PCT_B_SCALE_CAP)
def _live_bull_rsi_threshold_v2() -> float:
from app.core.config import settings
_rsi_threshold_val = strategy_cfg.get("MEAN_REVERSION_RSI_THRESHOLD")
_base = float(_rsi_threshold_val) if _rsi_threshold_val is not None else float(settings.MEAN_REVERSION_RSI_THRESHOLD)
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp = get_current_adaptive_params()
if not _adp.fallback_used:
_base = float(_adp.rsi_entry)
except Exception as _adp_err:
# [§4 2026-10-02] config/DB 폴백 유지 + warning(레이트리밋)
warn_throttled(logger, "mean_reversion:bull_rsi_overlay",
"[MeanReversion] 적응형 BULL RSI 임계 조회 실패 — strategy_cfg/config 값 사용: %s", _adp_err)
return _base
def _live_bb_pct_b_scale_v2() -> float:
_scale = 1.0
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp_bb = get_current_adaptive_params()
if not _adp_bb.fallback_used:
_scale = _bb_pct_b_to_scale_v2(_adp_bb.bb_pct_b)
except Exception as _adp_err:
_scale = 1.0
warn_throttled(logger, "mean_reversion:bb_scale_overlay",
"[MeanReversion] 적응형 %%B 스케일 조회 실패 — 1.0 사용: %s", _adp_err)
return _scale
def _live_bear_rsi_threshold_v2() -> float:
from app.core.config import settings
_bear_rsi_threshold = strategy_cfg.get("MEAN_REVERSION_BEAR_RSI_THRESHOLD")
_th = float(_bear_rsi_threshold) if _bear_rsi_threshold is not None else float(settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD)
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp_bear = get_current_adaptive_params()
if not _adp_bear.fallback_used:
_th = float(_adp_bear.bear_rsi_entry)
except Exception as _adp_err:
warn_throttled(logger, "mean_reversion:bear_rsi_overlay",
"[MeanReversion] 적응형 BEAR RSI 임계 조회 실패 — strategy_cfg/config 값 사용: %s", _adp_err)
return _th
# ─────────────────────────────────────────────────────────────────────────────
# 백테스트 전용 진입 판정 — 라이브 적응형 모드·DB(strategy_cfg)를 읽지 않는다
# ─────────────────────────────────────────────────────────────────────────────
def _bt_entry_param_v2(params: Dict[str, Any], key: str, default: float,
lo: float, hi: float) -> float:
"""백테스트 진입 파라미터: params[key] 가 유한하고 (lo, hi] 안이면 그 값, 아니면 default(+warning).
default 는 항상 settings(config.py) 값이다 — strategy_cfg(DB)·adaptive 는 보지 않는다.
"""
val = params.get(key) if params else None
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 < f <= hi):
logger.warning("[MeanReversionBT] 진입 파라미터 %s=%r 비정상 — 기본값 %s 사용", key, val, default)
return float(default)
return f
def _bt_bool_param_v2(params: Dict[str, Any], key: str, default: bool) -> bool:
val = params.get(key) if params else None
if val is None:
return bool(default)
if isinstance(val, str):
return val.strip().lower() in ("1", "true", "yes", "on")
return bool(val)
def check_buy_signal_backtest_v2(
params: Optional[Dict[str, Any]],
regime: MarketRegime,
rsi: float,
close: float,
bb_upper: float,
bb_middle: float,
bb_lower: float,
avg_volume_20d: float,
current_volume: int,
prev_volume: int,
is_reversal: bool = False,
rsi_prev: Optional[float] = None,
ma20: Optional[float] = None,
ma60: Optional[float] = None,
) -> Optional[Dict]:
"""백테스트 전용 진입 판정 (결정론) — `check_buy_signal` 과 로직 동일, 파라미터 출처만 다르다.
라이브 `check_buy_signal` 은 `get_current_adaptive_params()`(실행 시각의 라이브 모드, TTL 초과 시
백그라운드 refresh 로 네이버/KIS 조회까지 기동)와 `strategy_cfg`(운영 DB)를 읽어, 같은 입력이라도
실행 시각마다 결과가 달랐다. 이 함수는 둘 다 읽지 않는다. 모든 진입 임계는 `params` 로 명시
주입되고, 없으면 settings(config.py) 기본값이다 — 적응형 모드 미적용 = Normal/폴백 경로와 동일.
params 키 (모두 선택):
· rsi_entry BULL RSI 진입 임계 (기본 MEAN_REVERSION_RSI_THRESHOLD)
· bear_rsi_entry BEAR RSI 진입 임계 (기본 MEAN_REVERSION_BEAR_RSI_THRESHOLD)
· bb_pct_b 적응형 모드의 bb_pct_b — %B 임계 스케일 = bb_pct_b/0.30 (상한 1.3).
미지정이면 스케일 1.0
· bear_volume_mult BEAR 거래량 배수 (기본 MEAN_REVERSION_BEAR_VOLUME_MULT)
· bear_v2_enabled BEAR 점수제 v2 (기본 settings.MEAN_REVERSION_BEAR_V2_ENABLED)
· MEAN_REVERSION_BEAR_V2_* (config 키 이름 그대로) — v2 가중치·임계 오버라이드, 기본 settings
모드별 백테스트는 봉마다 해당 모드의 rsi_entry/bear_rsi_entry/bb_pct_b 를 params 로 넘긴다
(run_backtest.determine_adaptive_mode 방식).
"""
from app.core.config import settings
_p: Dict[str, Any] = dict(params or {})
def _bear_v2_cfg_bt(key: str) -> float:
_v = _p.get(key)
if _v is not None:
return float(_v)
return float(getattr(settings, key))
def _bull_rsi() -> float:
return _bt_entry_param_v2(_p, "rsi_entry", settings.MEAN_REVERSION_RSI_THRESHOLD, 0.0, 100.0)
def _bear_rsi() -> float:
return _bt_entry_param_v2(_p, "bear_rsi_entry", settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD, 0.0, 100.0)
def _bb_scale() -> float:
if _p.get("bb_pct_b") is None:
return 1.0
return _bb_pct_b_to_scale_v2(
_bt_entry_param_v2(_p, "bb_pct_b", _BB_PCT_B_NORMAL_REF, 0.0, 1.0))
def _bear_vol() -> float:
return _bt_entry_param_v2(_p, "bear_volume_mult", settings.MEAN_REVERSION_BEAR_VOLUME_MULT, 0.0, 10.0)
def _v2_enabled() -> bool:
return _bt_bool_param_v2(_p, "bear_v2_enabled", settings.MEAN_REVERSION_BEAR_V2_ENABLED)
return _check_buy_signal_impl(
regime=regime,
rsi=rsi,
close=close,
bb_upper=bb_upper,
bb_middle=bb_middle,
bb_lower=bb_lower,
avg_volume_20d=avg_volume_20d,
current_volume=current_volume,
prev_volume=prev_volume,
is_reversal=is_reversal,
rsi_prev=rsi_prev,
ma20=ma20,
ma60=ma60,
bull_rsi_fn=_bull_rsi,
bb_scale_fn=_bb_scale,
bear_rsi_fn=_bear_rsi,
bear_vol_mult_fn=_bear_vol,
bear_v2_enabled_fn=_v2_enabled,
bear_v2_cfg_get=_bear_v2_cfg_bt,
)
def _check_buy_signal_impl(
regime: MarketRegime,
rsi: float,
close: float,
bb_upper: float,
bb_middle: float,
bb_lower: float,
avg_volume_20d: float,
current_volume: int,
prev_volume: int,
is_reversal: bool,
rsi_prev: Optional[float],
ma20: Optional[float],
ma60: Optional[float],
bull_rsi_fn: Callable[[], float],
bb_scale_fn: Callable[[], float],
bear_rsi_fn: Callable[[], float],
bear_vol_mult_fn: Callable[[], float],
bear_v2_enabled_fn: Callable[[], bool],
bear_v2_cfg_get: Callable[[str], float],
) -> Optional[Dict]:
"""평균회귀 진입 판정 본체. 파라미터는 전부 주입된 리졸버(지연 호출)로만 얻는다 —
이 함수 자체는 strategy_cfg·adaptive 를 읽지 않는다(라이브/백테스트가 리졸버로 출처를 고른다)."""
from app.core.config import settings
# CLAUDE.md 6.1: 횡보장(SIDEWAYS)에서는 모든 진입 신호를 무시
if regime == MarketRegime.SIDEWAYS:
logger.debug("⏭️ SIDEWAYS 국면 — 진입 차단 (CLAUDE.md 6.1)")
@@ -489,20 +760,8 @@ def check_buy_signal(
if regime == MarketRegime.BULL:
# CLAUDE.md §6 / §8.2: BULL 진입 RSI < 46 (MEAN_REVERSION_RSI_THRESHOLD=46.0)
# 파라미터 우선순위: adaptive_strategy > strategy_cfg(DB) > settings(config.py)
# [2026-09-27] 하드코딩 40.0 폴백 제거 → settings 참조 (BEAR 경로와 동일 패턴)
from app.core.config import settings
_rsi_threshold_val = strategy_cfg.get("MEAN_REVERSION_RSI_THRESHOLD")
_base_rsi_threshold = float(_rsi_threshold_val) if _rsi_threshold_val is not None else settings.MEAN_REVERSION_RSI_THRESHOLD
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp = get_current_adaptive_params()
if not _adp.fallback_used:
_base_rsi_threshold = _adp.rsi_entry
except Exception as _adp_err:
# [§4 2026-10-02] config/DB 폴백 유지 + warning(레이트리밋)
warn_throttled(logger, "mean_reversion:bull_rsi_overlay",
"[MeanReversion] 적응형 BULL RSI 임계 조회 실패 — strategy_cfg/config 값 사용: %s", _adp_err)
# 임계 출처는 리졸버(라이브: adaptive > strategy_cfg > settings / 백테스트: params > settings)
_base_rsi_threshold = float(bull_rsi_fn())
rsi_ok = rsi < _base_rsi_threshold
if rsi_ok:
@@ -514,21 +773,8 @@ def check_buy_signal(
)
return None
# %B 기반 동적 BB 조건 (RSI 깊이에 따라 임계값 조정)
# 적응형 전략: bb_pct_b 오버라이드로 기본 pct_b_max 스케일 조정
_adaptive_bb_scale = 1.0 # 기본 스케일
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp_bb = get_current_adaptive_params()
if not _adp_bb.fallback_used:
# Normal 기준 0.30 대비 비율로 스케일링
_adaptive_bb_scale = _adp_bb.bb_pct_b / 0.30 if _adp_bb.bb_pct_b > 0 else 1.0
# S1 fix: BB 중심선(pct_b=0.5) 위 진입 방지 — 스케일 상한 1.3
_adaptive_bb_scale = min(_adaptive_bb_scale, 1.3)
except Exception as _adp_err:
_adaptive_bb_scale = 1.0
warn_throttled(logger, "mean_reversion:bb_scale_overlay",
"[MeanReversion] 적응형 %%B 스케일 조회 실패 — 1.0 사용: %s", _adp_err)
# %B 기반 동적 BB 조건 (RSI 깊이에 따라 임계값 조정) × 적응형 스케일(상한 1.3)
_adaptive_bb_scale = float(bb_scale_fn())
bb_width = bb_upper - bb_lower
pct_b = (close - bb_lower) / bb_width if bb_width > 0 else 0.5
@@ -547,10 +793,7 @@ def check_buy_signal(
"signal": "BUY",
"strategy": "MEAN_REVERSION_BULL",
"split_plan": [0.50, 0.50], # 2회 분할 매수
# [2026-09-27] 임계값 40 하드코딩 제거 — 실효 임계값은 _base_rsi_threshold
# (adaptive > strategy_cfg > settings 우선순위, CLAUDE.md §0)이며 46 이 기본이다.
# 40 을 박아두면 로그·TradeJournal·ML 학습 데이터에 거짓 임계값이 남는다.
# `%%B` → `%B` 도 정정(f-string 은 % 를 이스케이프하지 않아 그대로 출력됐다).
# [2026-09-27] 임계값 하드코딩 제거 — 실효 임계값(_base_rsi_threshold)을 기록한다.
"description": (
f"RSI({rsi:.1f})<{_base_rsi_threshold:.0f} + "
f"%B({pct_b:.2f})<{pct_b_max:.2f} (상승장 과매도){reversal_info}"
@@ -559,22 +802,13 @@ def check_buy_signal(
elif regime == MarketRegime.BEAR:
# CLAUDE.md §6 / §8.2: BEAR 진입 RSI < 35 (MEAN_REVERSION_BEAR_RSI_THRESHOLD=35.0)
# 파라미터 우선순위: adaptive_strategy > strategy_cfg(DB) > settings(config.py)
from app.core.config import settings
_bear_rsi_threshold = strategy_cfg.get("MEAN_REVERSION_BEAR_RSI_THRESHOLD")
bear_rsi_threshold = float(_bear_rsi_threshold) if _bear_rsi_threshold is not None else settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp_bear = get_current_adaptive_params()
if not _adp_bear.fallback_used:
bear_rsi_threshold = _adp_bear.bear_rsi_entry
except Exception as _adp_err:
warn_throttled(logger, "mean_reversion:bear_rsi_overlay",
"[MeanReversion] 적응형 BEAR RSI 임계 조회 실패 — strategy_cfg/config 값 사용: %s", _adp_err)
bear_rsi_threshold = float(bear_rsi_fn())
# [§8.5-9] BEAR 점수제 v2 — 기본 OFF. OFF면 아래 기존 4중 AND 경로가 그대로 동작한다.
if _bear_v2_enabled():
return check_bear_buy_signal_v2(
# ⚠️ 이 분기(및 아래 4중 AND)는 백테스트 전용이다(check_buy_signal 호출부 = 백테스트 엔진 2곳).
# 실전 BEAR 진입은 recommendations.is_mean_reversion_candidate 가 판정하며 v2 플래그를 보지 않는다.
if bear_v2_enabled_fn():
return _check_bear_buy_signal_v2_impl(
rsi=rsi,
close=close,
bb_upper=bb_upper,
@@ -584,6 +818,8 @@ def check_buy_signal(
is_reversal=is_reversal,
rsi_prev=rsi_prev,
bear_rsi_threshold=bear_rsi_threshold,
cfg_get=bear_v2_cfg_get,
bear_vol_mult_fn=bear_vol_mult_fn,
)
if rsi < bear_rsi_threshold:
@@ -605,8 +841,7 @@ def check_buy_signal(
else:
pct_b_max = 0.20
_bear_vol_mult = strategy_cfg.get("MEAN_REVERSION_BEAR_VOLUME_MULT")
bear_vol_mult = float(_bear_vol_mult) if _bear_vol_mult is not None else settings.MEAN_REVERSION_BEAR_VOLUME_MULT
bear_vol_mult = float(bear_vol_mult_fn())
if pct_b <= pct_b_max and is_reversal and current_volume > bear_vol_mult * avg_volume_20d:
return {
@@ -676,6 +911,118 @@ def resolve_swing_stop_params_v2(atr: Optional[float], close: float) -> Tuple[fl
return stop_loss_pct, float(atr_stop_mult)
def _trailing_mult_range_v2() -> Tuple[float, float]:
"""ATR 트레일링 배수 허용 범위 — strategy_configs 메타(DEFAULT_PARAMS min/max)가 단일 출처.
메타를 읽을 수 없으면 (0, inf) 대신 config 값 하나만 허용하는 효과가 나도록
호출부가 config 로 폴백한다(여기서는 넓은 범위를 만들지 않는다).
"""
from app.services.strategy_config_service import DEFAULT_PARAMS
_meta = DEFAULT_PARAMS["ATR_TRAILING_MULTIPLIER"]
return float(_meta[5]), float(_meta[6])
def _trailing_mult_or_none_v2(val, label: str) -> Optional[float]:
"""트레일링 배수 검증 — 범위 안의 유한한 양수면 float, 아니면 None(+warning)."""
if val is None:
return None
try:
f = float(val)
except (TypeError, ValueError):
warn_throttled(logger, f"mean_reversion:bad_trail_mult:{label}",
"[MeanReversion] 트레일링 배수 %s=%r 해석 불가 — 하위 소스 값 사용", label, val)
return None
if f != f or f in (float("inf"), float("-inf")):
warn_throttled(logger, f"mean_reversion:bad_trail_mult:{label}",
"[MeanReversion] 트레일링 배수 %s=%r 비정상 — 하위 소스 값 사용", label, val)
return None
try:
lo, hi = _trailing_mult_range_v2()
except Exception as _meta_err:
warn_throttled(logger, "mean_reversion:trail_mult_meta",
"[MeanReversion] 트레일링 배수 범위 메타 조회 실패 — 하위 소스 값 사용: %s", _meta_err)
return None
if not (lo <= f <= hi):
warn_throttled(logger, f"mean_reversion:bad_trail_mult:{label}",
"[MeanReversion] 트레일링 배수 %s=%r 범위[%s~%s] 밖 — 하위 소스 값 사용",
label, val, lo, hi)
return None
return f
def _adaptive_trailing_enabled_v2() -> bool:
"""적응형 트레일링 배수 적용 스위치. config 키 SWING_ADAPTIVE_TRAILING_ENABLED(미정의 시 False).
백테스트로 적응형 2.0/1.6/1.3/1.0 을 검증하기 전에는 False 유지(동작 = 1.5 고정과 동일).
"""
from app.core.config import settings
return getattr(settings, "SWING_ADAPTIVE_TRAILING_ENABLED", False) is True
def get_swing_trailing_mult_status_v2() -> Dict[str, object]:
"""상태 API/UI 용 — 실효 트레일링 배수와 적응형 값 적용 여부.
adaptive_strategy 상태 응답의 atr_trailing_multiplier(1.6 등)는 SWING_ADAPTIVE_TRAILING_ENABLED
가 False 면 **미적용**이다. 화면에 이 함수의 결과를 함께 노출해 혼동을 막는다.
"""
mult, source = _resolve_swing_trailing_atr_mult_impl()
return {
"effective_atr_trailing_multiplier": mult,
"source": source,
"adaptive_trailing_applied": source == "adaptive",
"adaptive_trailing_enabled": _adaptive_trailing_enabled_v2(),
}
def _resolve_swing_trailing_atr_mult_impl() -> Tuple[float, str]:
"""스윙 ATR 트레일링 배수 → (배수, 출처["config"|"strategy_cfg"|"adaptive"]).
우선순위(CLAUDE.md §0): adaptive > strategy_cfg(DB) > settings(config.py).
단 **적응형 값은 SWING_ADAPTIVE_TRAILING_ENABLED(기본 False) 일 때만** 적용한다.
적응형 _MODE_PARAMS 의 2.0/1.6/1.3/1.0 은 실전 동작을 바꾸므로 백테스트 전에는
켜지 않는다(이전 동작 = 항상 1.5). config/DB 기본값은 1.5 로 이전 리터럴과 동일하다.
예외 없이 항상 유효한 양수를 반환한다(최종 폴백은 config).
"""
from app.core.config import settings
mult = float(settings.ATR_TRAILING_MULTIPLIER)
source = "config"
_cfg = _trailing_mult_or_none_v2(strategy_cfg.get("ATR_TRAILING_MULTIPLIER"),
"strategy_cfg.ATR_TRAILING_MULTIPLIER")
if _cfg is not None:
mult, source = _cfg, "strategy_cfg"
if _adaptive_trailing_enabled_v2():
_adp = None
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp = get_current_adaptive_params()
except Exception as _adp_err:
warn_throttled(logger, "mean_reversion:trail_mult_overlay",
"[MeanReversion] 적응형 트레일링 배수 조회 실패 — strategy_cfg/config 값 사용: %s",
_adp_err)
_adp = None
if _adp is not None and not getattr(_adp, "fallback_used", True):
_adp_mult = _trailing_mult_or_none_v2(getattr(_adp, "atr_trailing_multiplier", None),
"adaptive.atr_trailing_multiplier")
if _adp_mult is not None:
mult, source = _adp_mult, "adaptive"
# 최종 가드: config 자체가 비정상(0·음수·NaN)이면 트레일링 스탑이 고점 이상(즉시 청산)이
# 되거나 의미를 잃는다 → strategy_configs 메타의 문서화 기본값으로 대체(+error).
if not (mult > 0) or mult != mult:
from app.services.strategy_config_service import DEFAULT_PARAMS
_default = float(DEFAULT_PARAMS["ATR_TRAILING_MULTIPLIER"][4])
logger.error("[MeanReversion] ATR 트레일링 배수 비정상(%r, 출처=%s) — 메타 기본값 %s 사용",
mult, source, _default)
mult, source = _default, "meta_default"
return float(mult), source
def resolve_swing_trailing_atr_mult_v2() -> float:
"""스윙 트레일링 스탑 배수(고점 − 배수×ATR) 단일 출처. 상세는 _impl 참조."""
return _resolve_swing_trailing_atr_mult_impl()[0]
def _stop_param_or_none_v2(val, label: str) -> Optional[float]:
"""손절 파라미터 값 검증 — 합리 범위 안의 유한한 숫자면 float, 아니면 None(+warning).
@@ -735,15 +1082,27 @@ def check_exit_signal(
- 분할매도 비율: 50% (Bull/Bear 동일, CLAUDE.md 5.2)
- 트레일링 스탑: 고점 - 1.5 * ATR (CLAUDE.md 5.2)
시나리오 (실제 동작 — 2026-10-02 정정):
시나리오 (실제 동작 — 2026-10-02 정정, 2026-10-09 R4 재정정):
1. 손절 체크 (최우선)
2. 1차 익절 (remaining_pct > 0.50 AND 수익 >= 0.5%)
└─ RSI >= rsi_partial_exit(기본 MEAN_REVERSION_RSI_EXIT_THRESHOLD)
OR BB중앙선 상회 OR 2×ATR 수익: 50% 매도 (sell_pct = 원본 수량 대비)
3. 전량 청산
└─ RSI >= rsi_full_exit OR BB 상단×MEAN_REVERSION_BB_UPPER_THRESHOLD 도달 (단계 무관)
OR 트레일링(고점 − 1.5×ATR 하회, **1차 익절 후 remaining_pct ≤ 0.50 에서만**)
└─ RSI >= rsi_full_exit OR BB 상단×MEAN_REVERSION_BB_UPPER_THRESHOLD 도달
OR 트레일링(고점 − 배수×ATR 하회 — 배수는 resolve_swing_trailing_atr_mult_v2, 기본 1.5; **1차 익절 후 remaining_pct ≤ 0.50 에서만**)
→ 보유 전량
⚠️ 단계 우선순위 (R4 2026-10-09 — "단계 무관"은 사실이 아니었다):
1차 익절 전(remaining_pct > 0.50) + 수익 ≥ 0.5% 이면 **2단계가 먼저 return** 한다.
RSI ≥ rsi_full_exit 이면 RSI ≥ rsi_partial_exit 도, BB상단×임계 도달이면 BB중앙 상회도
항상 함께 참이므로, 이 구간의 전량 조건은 **50% 분할로 처리**된다. 잔량은 다음 사이클
(swing/exit_service 쿨다운 후)에 전량 조건이 여전히 성립할 때 청산되고, 그 사이 되돌리면
트레일링 관리로 넘어간다. 전량 조건이 단계와 무관하게 즉시 전량으로 작동하는 것은
1차 익절 이후(remaining_pct ≤ 0.50) 인 경우뿐이다.
[R6 2026-10-10] RSI/BB상단 전량 조건도 수익 ≥ 0.5%(_MIN_PROFIT_FOR_EXIT) 일 때만 성립한다.
수익 < 0.5%(손실 포함)에서는 손절·시간손절·(1차 익절 후) 트레일링만 청산한다.
순서를 바꾸면 청산 타이밍이 달라지므로 백테스트(§8.1-5) 없이 변경하지 말 것.
(kr_backtest_engine 의 v2 청산도 같은 순서다.)
"""
# ── 1. 손절 (최우선) ──────────────────────────────────────────
from app.core.config import settings
@@ -821,22 +1180,30 @@ def check_exit_signal(
# ── 3. 전량매도 조건 ───────────────────────────────────────────────
# CLAUDE.md 6.3: RSI >= 전량청산 임계(rsi_full_exit / MEAN_REVERSION_RSI_FULL_EXIT)
# OR 가격 >= BB 상단 × MEAN_REVERSION_BB_UPPER_THRESHOLD OR 트레일링스탑 도달
# [2026-10-02 §5.2 "잔량 트레일링"] 트레일링(고점 − 1.5×ATR)은 1차 분할 익절 **이후**
# [2026-10-02 §5.2 "잔량 트레일링"] 트레일링(고점 − 배수×ATR)은 1차 분할 익절 **이후**
# (remaining_pct ≤ 0.50 — 분할 단계 기록값, sizing_core.remaining_pct_after_partial_sell)
# 에만 적용한다. 이전엔 remaining_pct 와 무관하게 1차 익절 전에도 보유 전량을 청산했다.
# 1차 익절 전 하락은 손절(1단계)이 담당한다.
from app.core.config import settings
full_exit_reasons = []
if rsi >= _adaptive_rsi_full:
# [R6 2026-10-10] RSI/BB상단 전량 '익절' 조건에도 1차 익절과 같은 최소수익 가드
# (_MIN_PROFIT_FOR_EXIT) 를 둔다. 이전엔 손실 구간(예: trade_history id 29, -0.42%)에서도
# '전량청산: BB상단 도달'로 손실을 확정하고 loss_tracker 연패에 쌓였다.
# 손실 구간 청산은 손절(1단계)·시간손절(exit_service)·트레일링(아래, 가드 없음)이 맡는다.
_full_take_profit_ok = _profit_pct >= _MIN_PROFIT_FOR_EXIT
if _full_take_profit_ok and rsi >= _adaptive_rsi_full:
full_exit_reasons.append(f"RSI({rsi:.1f})>={_adaptive_rsi_full}")
if close >= bb_upper * settings.MEAN_REVERSION_BB_UPPER_THRESHOLD:
if _full_take_profit_ok and close >= bb_upper * settings.MEAN_REVERSION_BB_UPPER_THRESHOLD:
full_exit_reasons.append(f"BB상단({bb_upper:.0f})도달")
_partial_done = remaining_pct <= threshold + 1e-9
if _partial_done and trailing_high is not None and trailing_high > entry_price:
trail_stop = trailing_high - 1.5 * atr # CLAUDE.md 5.2: ATR 기반 트레일링
# [R3 2026-10-09] 리터럴 1.5 → resolve_swing_trailing_atr_mult_v2
# (strategy_cfg > config, 적응형은 SWING_ADAPTIVE_TRAILING_ENABLED 일 때만). 기본값 동일.
_trail_mult = resolve_swing_trailing_atr_mult_v2()
trail_stop = trailing_high - _trail_mult * atr # CLAUDE.md 5.2: ATR 기반 트레일링
if close < trail_stop:
full_exit_reasons.append(
f"트레일링(close={close:.0f}<{trail_stop:.0f})"
f"트레일링(close={close:.0f}<{trail_stop:.0f}=고점-{_trail_mult:g}×ATR)"
)
if full_exit_reasons: