fix: 매매 파라미터 3중 소스 정합 + 리스크 가드 복원 + 죽은 코드 2,695줄 제거
## 배경 "전략이 혼란스럽고 현재와 맞지 않는다"를 조사한 결과, 전략 설계가 아니라 전략을 검증했다는 도구와 파라미터 소스가 어긋나 있었다. ## 파라미터 소스 단일화 - 우선순위가 adaptive > strategy_cfg(DB) > config.py 인데 문서는 맨 마지막인 config.py 를 기술하고 있었다. 11곳이 불일치(BULL RSI 46 vs DB 30, SL -7% vs -6%). - DEFAULT_PARAMS 를 config.py 에 정합 + alembic 0012 로 기존 DB 행 갱신 (운영자가 UI 로 바꾼 값은 보존, 옛 기본값과 같은 행만 갱신, 테이블 가드·멱등) ## 백테스트를 실전 로직에 정합 - calculate_adx_simple 이 정확히 period 배(14.0×) 발산 → Aggressive 게이트 `ADX>25` 가 전 레짐 100% 통과. 전환 빈도 5.6%→2.0%(-65%), 성과 상방 편향 - 백테스트 RSI 는 SMA, 실전은 Wilder → 진입 판정 10~11% 불일치 - us_backtest_engine RSI 1봉 지연 제거 (룩어헤드 없음을 절단 테스트로 증명) - backend/backtest_results/ 25건은 폐기 대상 (§8.4) ## 리스크 가드 복원 - 4% 절대 상한이 try 블록 안에 있어 예외 시 적응형 6~7% 가 통과 → try 밖으로 - 청산을 매수보다 먼저 평가 (보유 종목이 추천에 들면 손절 대신 물타기가 나갔다) - 리스크 가드 4개를 매수 전용화 → MDD -10%·보유한도 도달 시에도 손절 작동 - MDD 갱신 5회 연속 실패 시 신규 진입 차단 fail-safe (청산은 계속 허용) - 스윙 청산이 DT 보유분까지 매도하던 문제, sell_pct 기준 혼재 정정 ## 죽은 코드 제거 (2,695줄) - short_term_strategy, kosdaq_scanner, app/strategy/ 패키지 - app/trading/ + korea_investment_api_adapter (real_market_api 정본은 korea_investment_api.py:1482, adapter 는 import 0건) ## 지표·사이징 단일화 (수치 무변경 증명) - indicators_core.py: 3,274건 비교 최대 오차 0.000e+00 - sizing_core.py: 42,105건 비교 불일치 0 - 미국 전략 상수 13개 config 편입 (hex 까지 동일, 3,168 케이스 전수 비교) ## 테스트 안전 (실 KIS 호출 차단) - 유닛 테스트가 실제 KIS 에 매도 주문을 2회 전송하고 있었다. 리팩터링이 close_stale_positions 의 대상을 _execute_exit → _execute_exit_stale 로 바꿨는데 목이 전자에 걸려 빗나갔다. test_api_endpoints 도 캡처된 싱글톤 참조 누수로 실제 잔고 조회를 타 141초를 소비했다. - backend/tests/conftest.py 신설: 주문·토큰·시세·잔고를 호출 즉시 예외로 차단. 예외는 BaseException 파생(프로덕션 except Exception 폴백에 삼켜지지 않게), 클래스 메서드에 적용(캡처된 참조까지 덮음), DATABASE_URL `_test` 가드 강제 - 전체 유닛 스위트: 365초/6 failed → 11.8초/0 failed (소켓 차단 하 559 passed) ## 신규 (둘 다 플래그 OFF, 백테스트 미검증) - range_trading_strategy.py — SIDEWAYS 담당 전략이 없던 빈 칸. 미배선 - check_bear_buy_signal_v2 — BEAR 4중 AND(발화율 2.3%) → 점수제(12.4%) ## 문서 - CLAUDE.md 를 실제 코드 기준으로 재작성. §0 파라미터 우선순위, §4.1 테스트 규약, §6.4 문서화되지 않았던 진입 차단 게이트 9종, §8.4 백테스트 폐기 근거, §8.5 미해결 19항목 - 루트 md 6개 → 2개(docs/ 이동), 중복 배치 문서 병합, 스테일 worktree 제거 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
505
backend/app/services/range_trading_strategy.py
Normal file
505
backend/app/services/range_trading_strategy.py
Normal file
@@ -0,0 +1,505 @@
|
||||
"""박스권(SIDEWAYS) 레인지 매매 전략 — CLAUDE.md §8.5 항목 9 전반부
|
||||
|
||||
⚠️ **백테스트 미검증 — `RANGE_TRADE_ENABLED=True` 전환 금지 (CLAUDE.md §8.1-4)**
|
||||
|
||||
현재 상태: 코드만 존재. 실계좌·모의계좌 어느 쪽에서도 검증되지 않았다.
|
||||
`settings.RANGE_TRADE_ENABLED` 는 기본 False 이며, False 인 동안
|
||||
`swing/entry_service.scan_range_trading_signals()` 는 함수 진입 즉시 `[]` 를 반환한다
|
||||
(기존 동작 완전 불변).
|
||||
|
||||
**켜기 전에 반드시 통과해야 하는 검증 항목**
|
||||
1. SIDEWAYS 구간 라벨링 정확도 — `detect_market_regime()` 이 SIDEWAYS 로 판정한
|
||||
구간(2023-03~2026-03)을 추출하고, 그 구간에서만 이 전략을 돌린 결과를 측정한다.
|
||||
SIDEWAYS 표본이 60거래일 미만이면 통계적 의미 없음 → 판정 불가로 간주.
|
||||
2. In-sample 백테스트(2023-03~2025-03): 승률 ≥ 50%, Profit Factor ≥ 1.3,
|
||||
평균 보유 5~15거래일, MDD ≥ -8%(더 얕아야 함), 거래비용 0.5%(왕복) 반영 후 기준.
|
||||
3. Walk-forward (6개월 학습 / 3개월 검증, 최소 4구간): 모든 검증 구간에서
|
||||
PF ≥ 1.0, 어느 구간도 MDD -10% 초과 금지. 한 구간이라도 깨지면 불합격.
|
||||
4. 박스 이탈 손절의 실효성: 전체 거래 중 `box_break` 손절 비중과 그때의 평균 손실률을
|
||||
따로 집계. 평균 손실이 -RANGE_TRADE_STOP_LOSS_PERCENT 보다 크면(더 많이 잃으면)
|
||||
박스 이탈 판정 임계값(RANGE_TRADE_BOX_BREAK_PCT)을 재조정해야 한다.
|
||||
5. 파라미터 민감도: RANGE_TRADE_LOOKBACK_DAYS(15/20/25),
|
||||
RANGE_TRADE_MIN_WIDTH_PCT(5/6/8), RANGE_TRADE_RSI_ENTRY(35/40/45) 그리드에서
|
||||
PF 가 1.0 아래로 떨어지는 조합이 절반을 넘으면 과최적화로 판단 → 폐기.
|
||||
6. 기존 전략과의 중복/상관: 같은 날 평균회귀·눌림목과 동일 종목을 잡는 비율이 30%를
|
||||
넘으면 신규 신호원으로서의 가치가 없음 → 배선 재검토.
|
||||
7. 미검증 상태로 스케줄러에 배선하지 말 것(swing/scheduler.py 는 본 작업 범위 밖이며
|
||||
현재 호출부가 없다 — 보고서의 후속 작업 항목 참조).
|
||||
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
전략 개요 (순수 로직 — DB·네트워크 접근 없음)
|
||||
|
||||
진입: 성립된 박스권의 **하단 지지 반등**
|
||||
- 박스 성립 검증 (detect_range_box): 최근 N일 고/저 레인지 폭이 일정 범위,
|
||||
상단·하단을 각각 min_touches 회 이상 접촉, 접촉 순서가 교대(왕복 이력)해야 한다.
|
||||
- ADX 상한으로 추세장 배제 (박스권 전제 = 추세 없음).
|
||||
- 가격이 박스 하단 zone 안 + BB 하단 부근(%B) + RSI 과매도.
|
||||
- **반등 확인 필수**: RSI 상승 전환(rsi > rsi_prev) 또는 양봉. 둘 다 불가하면 진입 거부.
|
||||
- 거래량 ≥ 20일 평균 × 배수 (지지 반등의 수급 확인).
|
||||
|
||||
청산: 박스 상단 접근 시 익절 / 박스 이탈 시 즉시 손절
|
||||
- 1) 박스 하단 하향 이탈 → 즉시 전량 (레인지 전략의 최대 리스크)
|
||||
- 2) 일반 손절 (기본%·ATR 중 타이트한 쪽) → 전량
|
||||
- 3) 박스 상단 zone 도달 / BB 상단(99%) / RSI 전량임계 / 트레일링 → 전량
|
||||
- 4) 박스 중단(mid) 통과 또는 RSI 부분임계 → 50% 부분 익절 (수수료 가드 후)
|
||||
|
||||
손절 (CLAUDE.md §5.2):
|
||||
stop_price = max(atr_stop, pct_stop) ← 가격 기준에서 **높은 값이 타이트한 쪽**
|
||||
atr_stop = entry_price − RANGE_TRADE_ATR_STOP_MULTIPLIER × ATR
|
||||
pct_stop = entry_price × (1 + stop_loss_pct/100), stop_loss_pct = -abs(...) 보장
|
||||
박스 하단 이탈 손절은 위 stop_price 와 **별개의 OR 트리거**로 동작한다
|
||||
(박스 하단이 stop_price 보다 낮을 수도, 높을 수도 있어 max() 에 섞지 않는다).
|
||||
|
||||
모든 파라미터는 `app.core.config.settings` 의 `RANGE_TRADE_*` 설정에서 읽는다
|
||||
(하드코딩 금지 — 함수 인자로 오버라이드 가능하게 두어 백테스트 그리드 탐색에 사용).
|
||||
"""
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ActiveTrade.strategy_name 에 기록되는 값.
|
||||
# ⚠️ mdd_manager.SWING_STRATEGY_NAMES 에 이 값이 추가되어야 MDD 축소가 반영된다
|
||||
# (본 작업 범위 밖 — 보고서 후속 작업 항목 참조).
|
||||
RANGE_TRADE_STRATEGY_NAME = "RANGE_TRADE"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RangeBox:
|
||||
"""성립이 검증된 박스권."""
|
||||
high: float # 박스 상단 (lookback 구간 최고가)
|
||||
low: float # 박스 하단 (lookback 구간 최저가)
|
||||
mid: float # 박스 중앙
|
||||
width_pct: float # 박스 폭 / 중앙 × 100
|
||||
top_touches: int # 상단 접촉 봉 수
|
||||
bottom_touches: int # 하단 접촉 봉 수
|
||||
alternations: int # 상단↔하단 접촉 교대 횟수 (왕복 이력)
|
||||
lookback: int # 판정에 사용한 봉 수
|
||||
position_pct: float # 현재가의 박스 내 위치 (0=하단, 100=상단)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RangeExitSignal:
|
||||
"""청산 신호. mean_reversion_strategy.ExitSignal 과 동일한 필드 구성
|
||||
(action/reason/sell_pct) — swing/exit_service 의 소비 코드와 duck-typing 호환."""
|
||||
action: str # "SELL_PARTIAL" | "SELL_ALL"
|
||||
reason: str
|
||||
sell_pct: float # 0.5 = 50%, 1.0 = 전량
|
||||
|
||||
|
||||
def _cfg(name: str, override: Optional[float], default: float) -> float:
|
||||
"""설정값 조회: 명시 오버라이드 > settings.RANGE_TRADE_* > 하드 폴백.
|
||||
|
||||
하드 폴백은 config.py 가 아직 해당 키를 갖지 않는 경우(구버전 이미지)에만 쓰인다.
|
||||
"""
|
||||
if override is not None:
|
||||
return float(override)
|
||||
try:
|
||||
from app.core.config import settings
|
||||
val = getattr(settings, name, None)
|
||||
if val is not None:
|
||||
return float(val)
|
||||
except Exception:
|
||||
pass
|
||||
return float(default)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# 1. 박스권 성립 검증
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def detect_range_box(
|
||||
highs: List[float],
|
||||
lows: List[float],
|
||||
closes: List[float],
|
||||
lookback: Optional[int] = None,
|
||||
min_width_pct: Optional[float] = None,
|
||||
max_width_pct: Optional[float] = None,
|
||||
touch_tolerance_pct: Optional[float] = None,
|
||||
min_touches: Optional[int] = None,
|
||||
min_alternations: Optional[int] = None,
|
||||
box_break_pct: Optional[float] = None,
|
||||
) -> Optional[RangeBox]:
|
||||
"""최근 lookback 봉이 '박스권'인지 검증하고 박스를 반환. 실패 시 None.
|
||||
|
||||
검증 조건 (모두 AND):
|
||||
① 데이터 충분: len ≥ lookback
|
||||
② 박스 폭: min_width_pct ≤ (high-low)/mid×100 ≤ max_width_pct
|
||||
- 너무 좁으면 왕복 이익이 거래비용(왕복 ~0.5%)을 못 넘고,
|
||||
너무 넓으면 '박스'가 아니라 추세/고변동 구간이다.
|
||||
③ 상단·하단 접촉 각각 min_touches 회 이상 (tolerance 이내 접근을 접촉으로 간주)
|
||||
④ 접촉 순서 교대 min_alternations 회 이상 → 레인지 안에서 실제로 왕복한 이력
|
||||
(모든 하단 접촉이 앞쪽에만 몰려 있으면 하락 후 횡보일 뿐 박스가 아니다)
|
||||
⑤ 현재 종가가 박스를 이탈하지 않았음 (상·하단 ±box_break_pct 이내)
|
||||
"""
|
||||
_lookback = int(_cfg("RANGE_TRADE_LOOKBACK_DAYS", lookback, 20))
|
||||
_min_w = _cfg("RANGE_TRADE_MIN_WIDTH_PCT", min_width_pct, 6.0)
|
||||
_max_w = _cfg("RANGE_TRADE_MAX_WIDTH_PCT", max_width_pct, 20.0)
|
||||
_tol = _cfg("RANGE_TRADE_TOUCH_TOLERANCE_PCT", touch_tolerance_pct, 2.0)
|
||||
_min_touch = int(_cfg("RANGE_TRADE_MIN_TOUCHES", min_touches, 2))
|
||||
_min_alt = int(_cfg("RANGE_TRADE_MIN_ALTERNATIONS", min_alternations, 2))
|
||||
_break = _cfg("RANGE_TRADE_BOX_BREAK_PCT", box_break_pct, 1.0)
|
||||
|
||||
if _lookback <= 0:
|
||||
return None
|
||||
if len(highs) < _lookback or len(lows) < _lookback or len(closes) < 1:
|
||||
return None
|
||||
|
||||
win_h = highs[-_lookback:]
|
||||
win_l = lows[-_lookback:]
|
||||
box_high = max(win_h)
|
||||
box_low = min(win_l)
|
||||
if box_high <= 0 or box_low <= 0 or box_high <= box_low:
|
||||
return None
|
||||
|
||||
mid = (box_high + box_low) / 2.0
|
||||
if mid <= 0:
|
||||
return None
|
||||
width_pct = (box_high - box_low) / mid * 100.0
|
||||
if width_pct < _min_w or width_pct > _max_w:
|
||||
logger.debug(
|
||||
"[RangeBox] 폭 조건 미충족: width=%.2f%% (허용 %.1f~%.1f%%)",
|
||||
width_pct, _min_w, _max_w,
|
||||
)
|
||||
return None
|
||||
|
||||
# ③④ 접촉 및 교대 판정 (시간 순서 유지)
|
||||
top_line = box_high * (1.0 - _tol / 100.0)
|
||||
bottom_line = box_low * (1.0 + _tol / 100.0)
|
||||
events: List[str] = []
|
||||
top_touches = 0
|
||||
bottom_touches = 0
|
||||
for h, l in zip(win_h, win_l):
|
||||
is_top = h >= top_line
|
||||
is_bottom = l <= bottom_line
|
||||
if is_top and not is_bottom:
|
||||
top_touches += 1
|
||||
events.append("T")
|
||||
elif is_bottom and not is_top:
|
||||
bottom_touches += 1
|
||||
events.append("B")
|
||||
elif is_top and is_bottom:
|
||||
# 한 봉이 박스 전체를 커버 = 변동성 급증, 박스 판정 무효
|
||||
logger.debug("[RangeBox] 단일 봉이 박스 전폭 커버 — 박스 무효")
|
||||
return None
|
||||
|
||||
if top_touches < _min_touch or bottom_touches < _min_touch:
|
||||
logger.debug(
|
||||
"[RangeBox] 접촉 부족: top=%d, bottom=%d (각 %d회 이상 필요)",
|
||||
top_touches, bottom_touches, _min_touch,
|
||||
)
|
||||
return None
|
||||
|
||||
alternations = 0
|
||||
prev = None
|
||||
for e in events:
|
||||
if prev is not None and e != prev:
|
||||
alternations += 1
|
||||
prev = e
|
||||
if alternations < _min_alt:
|
||||
logger.debug(
|
||||
"[RangeBox] 왕복 이력 부족: alternations=%d (%d회 이상 필요) events=%s",
|
||||
alternations, _min_alt, "".join(events),
|
||||
)
|
||||
return None
|
||||
|
||||
# ⑤ 현재 종가의 박스 이탈 여부
|
||||
close = closes[-1]
|
||||
if close < box_low * (1.0 - _break / 100.0):
|
||||
logger.debug("[RangeBox] 하단 이탈 상태 (close=%.1f < low=%.1f) — 박스 무효", close, box_low)
|
||||
return None
|
||||
if close > box_high * (1.0 + _break / 100.0):
|
||||
logger.debug("[RangeBox] 상단 이탈 상태 (close=%.1f > high=%.1f) — 박스 무효", close, box_high)
|
||||
return None
|
||||
|
||||
position_pct = (close - box_low) / (box_high - box_low) * 100.0
|
||||
return RangeBox(
|
||||
high=box_high,
|
||||
low=box_low,
|
||||
mid=mid,
|
||||
width_pct=width_pct,
|
||||
top_touches=top_touches,
|
||||
bottom_touches=bottom_touches,
|
||||
alternations=alternations,
|
||||
lookback=_lookback,
|
||||
position_pct=position_pct,
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# 2. 진입 판정
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def check_range_buy_signal(
|
||||
highs: List[float],
|
||||
lows: List[float],
|
||||
closes: List[float],
|
||||
volumes: List[int],
|
||||
current_close: float,
|
||||
current_volume: int,
|
||||
rsi: float,
|
||||
bb_upper: float,
|
||||
bb_middle: float,
|
||||
bb_lower: float,
|
||||
adx: Optional[float],
|
||||
rsi_prev: Optional[float] = None,
|
||||
opens: Optional[List[float]] = None,
|
||||
box: Optional[RangeBox] = None,
|
||||
rsi_entry: Optional[float] = None,
|
||||
bb_pct_b_max: Optional[float] = None,
|
||||
entry_zone_pct: Optional[float] = None,
|
||||
max_adx: Optional[float] = None,
|
||||
volume_ratio: Optional[float] = None,
|
||||
) -> Optional[Dict]:
|
||||
"""박스권 하단 지지 반등 매수 신호. 조건 미충족 시 None.
|
||||
|
||||
반환 dict 키는 mean_reversion_strategy.check_buy_signal() 과 동일한 관례를 따른다
|
||||
(signal/strategy/description) + 레인지 전용 필드(box_high/box_low/target_price).
|
||||
|
||||
Args:
|
||||
adx: 일봉 ADX(14). **None 이면 진입 거부**(추세장 배제 판정 불가 = fail-closed).
|
||||
rsi_prev: N봉 전 RSI. RSI 상승 전환 확인용.
|
||||
opens: 시가 리스트. 양봉 확인용. rsi_prev·opens 둘 다 없으면 반등 확인 불가 → 거부.
|
||||
"""
|
||||
_rsi_entry = _cfg("RANGE_TRADE_RSI_ENTRY", rsi_entry, 40.0)
|
||||
_pct_b_max = _cfg("RANGE_TRADE_BB_PCT_B_MAX", bb_pct_b_max, 0.25)
|
||||
_zone = _cfg("RANGE_TRADE_ENTRY_ZONE_PCT", entry_zone_pct, 25.0)
|
||||
_max_adx = _cfg("RANGE_TRADE_MAX_ADX", max_adx, 20.0)
|
||||
_vol_ratio = _cfg("RANGE_TRADE_VOLUME_RATIO", volume_ratio, 1.0)
|
||||
|
||||
if current_close <= 0 or rsi is None:
|
||||
return None
|
||||
|
||||
# ── ① 추세장 배제 (ADX 상한) ──────────────────────────────────────────
|
||||
if adx is None:
|
||||
logger.debug("[RangeBuy] ADX 없음 — 추세 판정 불가로 진입 거부(fail-closed)")
|
||||
return None
|
||||
if adx >= _max_adx:
|
||||
logger.debug("[RangeBuy] ADX(%.1f) >= %.1f — 추세장, 레인지 진입 거부", adx, _max_adx)
|
||||
return None
|
||||
|
||||
# ── ② 박스 성립 검증 ────────────────────────────────────────────────
|
||||
_box = box if box is not None else detect_range_box(highs, lows, closes)
|
||||
if _box is None:
|
||||
return None
|
||||
|
||||
# ── ③ 박스 하단 zone 안인지 ──────────────────────────────────────────
|
||||
if _box.position_pct > _zone:
|
||||
logger.debug(
|
||||
"[RangeBuy] 박스 내 위치 %.1f%% > %.1f%% — 하단 지지대 아님",
|
||||
_box.position_pct, _zone,
|
||||
)
|
||||
return None
|
||||
|
||||
# ── ④ BB 하단 부근 (%B) ─────────────────────────────────────────────
|
||||
bb_width = bb_upper - bb_lower
|
||||
pct_b = (current_close - bb_lower) / bb_width if bb_width > 0 else 0.5
|
||||
if pct_b > _pct_b_max:
|
||||
logger.debug("[RangeBuy] %%B(%.2f) > %.2f — BB 하단 부근 아님", pct_b, _pct_b_max)
|
||||
return None
|
||||
|
||||
# ── ⑤ RSI 과매도 ────────────────────────────────────────────────────
|
||||
if rsi >= _rsi_entry:
|
||||
logger.debug("[RangeBuy] RSI(%.1f) >= %.1f — 과매도 아님", rsi, _rsi_entry)
|
||||
return None
|
||||
|
||||
# ── ⑥ 반등 확인 필수 (RSI 상승 전환 OR 양봉) ─────────────────────────
|
||||
rsi_turning_up = rsi_prev is not None and rsi > rsi_prev
|
||||
bullish_candle = False
|
||||
if opens and len(opens) >= 1 and len(closes) >= 1:
|
||||
bullish_candle = closes[-1] > opens[-1]
|
||||
if not (rsi_turning_up or bullish_candle):
|
||||
logger.debug(
|
||||
"[RangeBuy] 반등 미확인 — 진입 거부 (rsi=%.1f, rsi_prev=%s, 양봉=%s)",
|
||||
rsi, f"{rsi_prev:.1f}" if rsi_prev is not None else "N/A", bullish_candle,
|
||||
)
|
||||
return None
|
||||
|
||||
# ── ⑦ 거래량 확인 ───────────────────────────────────────────────────
|
||||
if volumes and len(volumes) >= 20:
|
||||
avg_vol_20 = sum(volumes[-20:]) / 20.0
|
||||
if avg_vol_20 > 0 and current_volume < avg_vol_20 * _vol_ratio:
|
||||
logger.debug(
|
||||
"[RangeBuy] 거래량 부족: %d < %.0f×%.2f",
|
||||
current_volume, avg_vol_20, _vol_ratio,
|
||||
)
|
||||
return None
|
||||
elif current_volume <= 0:
|
||||
return None
|
||||
|
||||
# 목표가: 박스 상단과 BB 상단 중 보수적(낮은) 쪽
|
||||
target_price = min(_box.high, bb_upper) if bb_upper > 0 else _box.high
|
||||
reversal_desc = []
|
||||
if rsi_turning_up:
|
||||
reversal_desc.append(f"RSI반등({rsi_prev:.1f}→{rsi:.1f})")
|
||||
if bullish_candle:
|
||||
reversal_desc.append("양봉")
|
||||
|
||||
return {
|
||||
"signal": "BUY",
|
||||
"strategy": RANGE_TRADE_STRATEGY_NAME,
|
||||
"confidence": 0.55, # 미검증 전략 — 보수적 고정값 (신뢰도 상향은 백테스트 후)
|
||||
"box_high": _box.high,
|
||||
"box_low": _box.low,
|
||||
"box_mid": _box.mid,
|
||||
"box_width_pct": _box.width_pct,
|
||||
"position_pct": _box.position_pct,
|
||||
"target_price": target_price,
|
||||
"description": (
|
||||
f"박스권 하단 지지 반등: 박스[{_box.low:.0f}~{_box.high:.0f}] "
|
||||
f"폭{_box.width_pct:.1f}% 접촉(T{_box.top_touches}/B{_box.bottom_touches}) "
|
||||
f"왕복{_box.alternations} 위치{_box.position_pct:.0f}% "
|
||||
f"RSI({rsi:.1f})<{_rsi_entry:.0f} %B({pct_b:.2f}) ADX({adx:.1f})<{_max_adx:.0f} "
|
||||
f"{'+'.join(reversal_desc)}"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# 3. 손절가 계산 (CLAUDE.md §5.2)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def calculate_range_stop_loss(
|
||||
entry_price: float,
|
||||
atr: float,
|
||||
box_low: float,
|
||||
atr_multiplier: Optional[float] = None,
|
||||
stop_loss_percent: Optional[float] = None,
|
||||
box_break_pct: Optional[float] = None,
|
||||
) -> Dict[str, float]:
|
||||
"""레인지 전략 손절가.
|
||||
|
||||
CLAUDE.md §5.2: 기본 SL 과 ATR SL 중 **타이트한 쪽**.
|
||||
가격 기준 비교에서는 더 **높은** 손절가가 타이트하므로 `max(atr_stop, pct_stop)`.
|
||||
(수익률 기준으로 비교할 때의 `min()` 과 방향이 반대인 점 주의)
|
||||
|
||||
박스 하단 이탈 손절(box_break_stop)은 별개의 OR 트리거로 반환한다 —
|
||||
max() 에 섞으면 박스가 넓을 때 손절이 과도하게 느슨해지거나(하단이 낮음),
|
||||
박스가 좁을 때 정상 노이즈에서 조기 손절(하단이 높음)될 수 있다.
|
||||
"""
|
||||
_atr_mult = _cfg("RANGE_TRADE_ATR_STOP_MULTIPLIER", atr_multiplier, 2.0)
|
||||
_sl_pct_raw = _cfg("RANGE_TRADE_STOP_LOSS_PERCENT", stop_loss_percent, 5.0)
|
||||
_break = _cfg("RANGE_TRADE_BOX_BREAK_PCT", box_break_pct, 1.0)
|
||||
|
||||
stop_loss_pct = -abs(_sl_pct_raw) # 부호 가드 — 양수 입력 시 전종목 매도 방지
|
||||
atr_stop = entry_price - _atr_mult * max(0.0, float(atr or 0.0))
|
||||
pct_stop = entry_price * (1.0 + stop_loss_pct / 100.0)
|
||||
stop_price = max(atr_stop, pct_stop) # 높은 값 = 타이트한 손절
|
||||
box_break_stop = box_low * (1.0 - _break / 100.0) if box_low > 0 else 0.0
|
||||
|
||||
return {
|
||||
"stop_price": stop_price,
|
||||
"atr_stop": atr_stop,
|
||||
"pct_stop": pct_stop,
|
||||
"stop_loss_pct": stop_loss_pct,
|
||||
"box_break_stop": box_break_stop,
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# 4. 청산 판정
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def check_range_exit_signal(
|
||||
rsi: float,
|
||||
close: float,
|
||||
bb_upper: float,
|
||||
bb_middle: float,
|
||||
bb_lower: float,
|
||||
entry_price: float,
|
||||
atr: float,
|
||||
box_high: float,
|
||||
box_low: float,
|
||||
remaining_pct: float = 1.0,
|
||||
trailing_high: Optional[float] = None,
|
||||
rsi_partial_exit: Optional[float] = None,
|
||||
rsi_full_exit: Optional[float] = None,
|
||||
exit_zone_pct: Optional[float] = None,
|
||||
trailing_atr_mult: Optional[float] = None,
|
||||
min_profit_pct: Optional[float] = None,
|
||||
) -> Optional[RangeExitSignal]:
|
||||
"""레인지 청산 판정. 우선순위대로 평가한다.
|
||||
|
||||
1. 박스 하단 하향 이탈 → 즉시 전량 (레인지 전략의 핵심 리스크)
|
||||
2. 일반 손절 max(atr_stop, pct_stop) → 전량
|
||||
3. 박스 상단 zone / BB 상단(99%) / RSI 전량임계 / 트레일링 → 전량
|
||||
4. 박스 중앙 상회 또는 RSI 부분임계 → 50% 부분 익절 (수수료 가드 통과 시)
|
||||
"""
|
||||
_rsi_partial = _cfg("RANGE_TRADE_RSI_PARTIAL_EXIT", rsi_partial_exit, 55.0)
|
||||
_rsi_full = _cfg("RANGE_TRADE_RSI_FULL_EXIT", rsi_full_exit, 65.0)
|
||||
_exit_zone = _cfg("RANGE_TRADE_EXIT_ZONE_PCT", exit_zone_pct, 80.0)
|
||||
_trail_mult = _cfg("RANGE_TRADE_TRAILING_ATR_MULT", trailing_atr_mult, 1.5)
|
||||
_min_profit = _cfg("RANGE_TRADE_MIN_PROFIT_PCT", min_profit_pct, 0.5)
|
||||
|
||||
if close <= 0 or entry_price <= 0:
|
||||
return None
|
||||
|
||||
stops = calculate_range_stop_loss(entry_price, atr, box_low)
|
||||
|
||||
# ── 1. 박스 하단 이탈 → 즉시 전량 손절 ──────────────────────────────
|
||||
if stops["box_break_stop"] > 0 and close <= stops["box_break_stop"]:
|
||||
return RangeExitSignal(
|
||||
action="SELL_ALL",
|
||||
reason=(
|
||||
f"박스 이탈 손절 (close={close:.0f} <= 박스하단이탈선"
|
||||
f"={stops['box_break_stop']:.0f}, box_low={box_low:.0f})"
|
||||
),
|
||||
sell_pct=1.0,
|
||||
)
|
||||
|
||||
# ── 2. 일반 손절 (기본%·ATR 중 타이트한 쪽) ─────────────────────────
|
||||
if close <= stops["stop_price"]:
|
||||
return RangeExitSignal(
|
||||
action="SELL_ALL",
|
||||
reason=(
|
||||
f"손절 (close={close:.0f} <= stop={stops['stop_price']:.0f}, "
|
||||
f"ATR기준={stops['atr_stop']:.0f}, {stops['stop_loss_pct']:.1f}%기준"
|
||||
f"={stops['pct_stop']:.0f})"
|
||||
),
|
||||
sell_pct=1.0,
|
||||
)
|
||||
|
||||
profit_pct = (close - entry_price) / entry_price * 100.0
|
||||
|
||||
# ── 3. 전량 익절 조건 ──────────────────────────────────────────────
|
||||
full_reasons: List[str] = []
|
||||
if box_high > box_low:
|
||||
pos_pct = (close - box_low) / (box_high - box_low) * 100.0
|
||||
if pos_pct >= _exit_zone:
|
||||
full_reasons.append(f"박스상단zone(위치{pos_pct:.0f}%>={_exit_zone:.0f}%)")
|
||||
if bb_upper > 0 and close >= bb_upper * 0.99:
|
||||
full_reasons.append(f"BB상단({bb_upper:.0f})도달")
|
||||
if rsi is not None and rsi >= _rsi_full:
|
||||
full_reasons.append(f"RSI({rsi:.1f})>={_rsi_full:.0f}")
|
||||
if trailing_high is not None and trailing_high > entry_price and atr > 0:
|
||||
trail_stop = trailing_high - _trail_mult * atr
|
||||
if close < trail_stop:
|
||||
full_reasons.append(f"트레일링(close={close:.0f}<{trail_stop:.0f})")
|
||||
|
||||
# 익절은 수수료(왕복 ~0.5%)를 커버할 때만. 트레일링은 손실 보호이므로 예외.
|
||||
_has_trailing = any(r.startswith("트레일링") for r in full_reasons)
|
||||
if full_reasons and (profit_pct >= _min_profit or _has_trailing):
|
||||
return RangeExitSignal(
|
||||
action="SELL_ALL",
|
||||
reason=f"전량청산: {' OR '.join(full_reasons)}",
|
||||
sell_pct=1.0,
|
||||
)
|
||||
|
||||
# ── 4. 부분 익절 (50%) ─────────────────────────────────────────────
|
||||
if remaining_pct > 0.50 and profit_pct >= _min_profit:
|
||||
partial_reasons: List[str] = []
|
||||
if rsi is not None and rsi >= _rsi_partial:
|
||||
partial_reasons.append(f"RSI({rsi:.1f})>={_rsi_partial:.0f}")
|
||||
if bb_middle > 0 and close > bb_middle:
|
||||
partial_reasons.append(f"BB중앙({bb_middle:.0f})상회")
|
||||
if box_high > box_low and close > (box_high + box_low) / 2.0:
|
||||
partial_reasons.append("박스중앙상회")
|
||||
if partial_reasons:
|
||||
return RangeExitSignal(
|
||||
action="SELL_PARTIAL",
|
||||
reason=f"1차 익절(50%): {' OR '.join(partial_reasons)}",
|
||||
sell_pct=0.50,
|
||||
)
|
||||
|
||||
return None
|
||||
Reference in New Issue
Block a user