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:
2026-09-28 08:58:18 +09:00
parent b4608e5abf
commit 1b9257c4d2
71 changed files with 5510 additions and 3685 deletions

View File

@@ -132,9 +132,28 @@ async def execute_swing_entries(recommendations_response: dict, db: AsyncSession
_inject_strategy_type(rec, "swing")
# 기존 execute_auto_trade_background 재활용 (시그니처 변경 없음)
# [2026-09-27] execute_auto_trade_background() 는 항상 list 를 반환한다(과거엔
# 암묵적 None). 이 list 에는 성공한 BUY 뿐 아니라 주문 실패(success=False)한
# BUY/SELL, 청산 우선 로직으로 섞인 SELL 도 함께 담기므로 len(result) 를 그대로
# "진입 n건"으로 로깅하지 않고 action/success 기준으로 분리 집계한다.
try:
result = await execute_auto_trade_background(rec, db)
if isinstance(result, list):
buy_success = sum(
1 for r in result
if isinstance(r, dict) and r.get("action") == "BUY" and r.get("success")
)
sell_count = sum(
1 for r in result if isinstance(r, dict) and r.get("action") == "SELL"
)
fail_count = sum(
1 for r in result if isinstance(r, dict) and not r.get("success")
)
if buy_success or sell_count or fail_count:
logger.info(
"[SwingEntry] 진입 %d건 / 청산 %d건 / 주문실패 %d건",
buy_success, sell_count, fail_count,
)
return result
except Exception as e:
logger.error(f"[SwingEntry] 진입 실행 오류: {e}", exc_info=True)
@@ -185,6 +204,7 @@ async def scan_dual_strategy_signals(db: AsyncSession) -> list[dict]:
check_pullback_buy_signal,
calculate_rsi,
)
from app.services.signal_types import BuySignal
if not is_market_open():
return []
@@ -284,6 +304,16 @@ async def scan_dual_strategy_signals(db: AsyncSession) -> list[dict]:
"stock_name": stock.stock_name,
"current_price": int(current_close),
"score": 0.8,
# [2026-09-27 FIX] signals(복수) 누락으로 trade_executor 의
# should_buy 판정(:1906)이 항상 False 였다 → ML 앙상블이 독립적으로
# BUY 를 낼 때만 진입되던 버그. 진입 근거와 **의미가 일치하는**
# 키워드만 채운다(거짓 신호는 저널·ML 학습 데이터를 오염시킴).
# · 신고가돌파 ← current_close > max(highs[-20:]) (StrongBuySignal)
# · 거래량증가상승 ← vol_ratio >= 2.0 & 상승 돌파
"signals": [
BuySignal.신고가돌파.value,
BuySignal.거래량증가상승.value,
],
"signal": momentum_signal["description"],
"strategy_type": "swing",
# ActiveTrade.strategy_name 으로 저장돼 swing/exit_service 의 모멘텀 전용
@@ -308,6 +338,16 @@ async def scan_dual_strategy_signals(db: AsyncSession) -> list[dict]:
"stock_name": stock.stock_name,
"current_price": int(current_close),
"score": 0.6,
# [2026-09-27 FIX] 모멘텀 돌파와 동일 사유. check_pullback_buy_signal 의
# 진입 근거와 1:1 대응하는 키워드만 채운다.
# · 정배열눌림목 ← MA20>MA60(정배열) & MA20 ±3% 조정 (StrongBuySignal)
# · 눌림목진입 ← RSI 40~55 반등 + 거래량 ≥1.2×20MA (StrongBuySignal)
# · MA20상향 ← ma20 > ma60 ("20일선 > 60일선")
"signals": [
BuySignal.정배열눌림목.value,
BuySignal.눌림목진입.value,
BuySignal.MA20상향.value,
],
"signal": pullback_signal["description"],
"strategy_type": "swing",
"strategy_name": "PULLBACK_BUY",
@@ -338,12 +378,344 @@ async def scan_dual_strategy_signals(db: AsyncSession) -> list[dict]:
}]
}
# [관측성] execute_auto_trade_background() 는 [2026-09-27 수정]으로 내부
# trade_results 를 항상 list 로 반환한다(과거엔 암묵적 None 을 돌려줘 아래
# isinstance(result, list) 가 항상 False 였다 — 지금은 정상 동작).
# 다만 이 list 에는 성공한 BUY 뿐 아니라 **주문 실패(success=False)한
# BUY/SELL 과, 청산 우선 로직으로 섞여 들어온 SELL 도 함께 담긴다.**
# 따라서 단순 len(result) 는 "진입 n건"으로 오인시킬 수 있어, 아래에서
# action/success 기준으로 분리 집계한다. 위임 직전 후보 로그는 관측성
# 확보를 위해 계속 남긴다.
logger.info(
"[DualStrategy] %d건 시그널 → 매매 파이프라인 위임: %s",
len(dual_stocks),
", ".join(
f"{s['stock_code']}({s.get('strategy_name')})" for s in dual_stocks
),
)
try:
result = await execute_auto_trade_background(rec, db)
if isinstance(result, list):
logger.info(f"[DualStrategy] {len(result)}건 진입 실행 완료")
buy_success = sum(
1 for r in result
if isinstance(r, dict) and r.get("action") == "BUY" and r.get("success")
)
sell_count = sum(
1 for r in result if isinstance(r, dict) and r.get("action") == "SELL"
)
fail_count = sum(
1 for r in result if isinstance(r, dict) and not r.get("success")
)
logger.info(
"[DualStrategy] 진입 %d건 / 청산 %d건 / 주문실패 %d건",
buy_success, sell_count, fail_count,
)
return result
# [2026-09-27] execute_auto_trade_background() 는 이제 항상 list 를 반환하므로
# 이 분기는 정상 경로에서 도달하지 않는다. 다만 향후 시그니처 회귀(반환 타입
# 변경) 시 조용히 무시되지 않도록 isinstance 방어는 유지하고 경고로 남긴다.
logger.warning(
"[DualStrategy] 예상치 못한 반환 타입(list 아님, %r) — 방어 로직 발동",
type(result),
)
except Exception as e:
logger.error(f"[DualStrategy] 진입 실행 오류: {e}", exc_info=True)
return []
async def scan_range_trading_signals(db: AsyncSession) -> list[dict]:
"""박스권(SIDEWAYS) 레인지 매매 시그널 스캔 및 진입 실행.
CLAUDE.md §8.5 항목 9 — SIDEWAYS 국면 담당 전략이 없어 코스피가 BULL 이 아닌
구간에서 거래가 전무한 문제를 메우기 위한 **신규 신호원**.
⚠️ **백테스트 미검증.** `settings.RANGE_TRADE_ENABLED` 가 False(기본값)이면
함수 진입 즉시 [] 를 반환한다 — 어떤 조회·판정·주문도 수행하지 않으므로
플래그가 꺼져 있는 동안 기존 동작은 완전히 불변이다.
┌─ SIDEWAYS 차단 우회에 관한 명시적 설계 결정 ────────────────────────────┐
│ 이 전략은 **SIDEWAYS 국면 전용**이므로, CLAUDE.md §6.1 의 "SIDEWAYS = │
│ 모든 진입 무시" 전면 차단을 의도적으로 우회한다. │
│ · market_analyzer.check_market_regime() 은 sideways 에서 │
│ position_multiplier=0, max_trades=0 을 반환한다. │
│ → execute_auto_trade_background() 는 max_trades==0 에서 즉시 []을 │
│ 반환하므로(2026-09-27 수정 이후에도 동일 — 반환 타입만 list로 정정 │
│ 됐을 뿐 조기 종료 자체는 그대로) 사용할 수 없다. 종목별로 │
│ execute_auto_trade() 를 직접 호출하고 │
│ _position_multiplier 를 RANGE_TRADE_POSITION_MULTIPLIER 로 주입한다. │
│ · trade_executor._execute_buy 는 _regime_str=="sideways" 를 하드 차단 │
│ 한다(방어코드). 따라서 자본 한도가 가장 보수적인 "bear" 라벨을 주입해 │
│ BEAR 전략 자본 상한(30%) 아래에서 동작시킨다. │
│ · 우회는 **RANGE_TRADE_ENABLED 뒤에서만** 일어난다. market_analyzer 와 │
│ mean_reversion_strategy 는 수정하지 않았으므로 다른 전략(평균회귀· │
│ 모멘텀·눌림목)의 SIDEWAYS 차단은 그대로 유지된다. │
└──────────────────────────────────────────────────────────────────────────┘
리스크 게이트 (scan_dual_strategy_signals 와 동일 + 레인지 전용 추가):
1. RANGE_TRADE_ENABLED 플래그
2. 장 오픈 여부 (정규장만)
3. 시장 국면 == SIDEWAYS (조회 실패 시 진입 중단 — fail-closed)
4. 스윙 포지션 한도 (MAX_SWING_POSITIONS)
5. 레인지 전용 포지션 한도 (RANGE_TRADE_MAX_POSITIONS ∩ 자본 비율 유도 상한)
6. MDD (await mdd_manager.can_open_new_position())
7. 적응형 Crisis 모드 (조회 실패 시 fail-closed)
8. 플레이북 이벤트 차단
9. 보유 종목 중복 진입 방지
10. 연속손실 밴 (loss_tracker.is_banned_safe)
11. 가격 필터 (MIN_PRICE_KRW)
12. 이후 execute_auto_trade() 내부에서 가격필터·MDD·밴·보유수·당일매도쿨다운,
_execute_buy 내부에서 correlation_filter·레버리지ETF한도·뉴스필터·
MIN_BUY_AMOUNT_KRW 가 다시 적용된다.
Returns:
list[dict]: 실행된 매수 결과 목록
"""
# ── 게이트 1: 기능 플래그 (백테스트 미검증 — 기본 False) ──────────────
if not settings.RANGE_TRADE_ENABLED:
return []
from app.core.market_schedule import is_market_open
from app.models.models import ActiveTrade
from app.services.korea_investment_api import korea_investment_api
from app.services.loss_tracker import loss_tracker
from app.services.mean_reversion_strategy import (
calculate_adx,
calculate_atr,
calculate_bollinger,
calculate_rsi,
)
from app.services.range_trading_strategy import (
RANGE_TRADE_STRATEGY_NAME,
calculate_range_stop_loss,
check_range_buy_signal,
)
from app.services.signal_types import BuySignal
# ── 게이트 2: 정규장만 ────────────────────────────────────────────────
if not is_market_open():
return []
# ── 게이트 3: SIDEWAYS 국면에서만 동작 (fail-closed) ──────────────────
try:
regime = await check_market_regime()
except Exception as e:
logger.warning(f"[RangeTrade] 시장 국면 조회 실패 — 진입 중단(fail-closed): {e}")
return []
regime_str = regime.get("regime_200d", "") if isinstance(regime, dict) else ""
if regime_str != "sideways":
logger.debug("[RangeTrade] SIDEWAYS 아님 (%s) — 레인지 전략 스킵", regime_str or "unknown")
return []
# ── 게이트 4: 스윙 전체 포지션 한도 ───────────────────────────────────
current_count = await count_swing_positions(db)
remaining_slots = MAX_SWING_POSITIONS - current_count
if remaining_slots <= 0:
logger.info("[RangeTrade] 스윙 포지션 한도 도달 (%d) — 스킵", current_count)
return []
# ── 게이트 5: 레인지 전용 포지션/자본 한도 ────────────────────────────
# 자본 배분: 새 자본 몫을 만들지 않고 스윙 예산(SWING_MAX_CAPITAL_RATIO=0.70)을
# 공유한다. 레인지 몫은 스윙 몫 **내부** 비율(RANGE_TRADE_MAX_CAPITAL_RATIO).
# 실효 상한 = 총자본 × 0.70 × 0.10 = 7%
# 종목당 = MAX_POSITION_SIZE(4%) × RANGE_TRADE_POSITION_MULTIPLIER(0.5) = 2%
# → 허용 종목 수 = floor(7% / 2%) = 3
_per_position_ratio = settings.MAX_POSITION_SIZE * settings.RANGE_TRADE_POSITION_MULTIPLIER
_range_capital_ratio = settings.SWING_MAX_CAPITAL_RATIO * settings.RANGE_TRADE_MAX_CAPITAL_RATIO
_ratio_cap = int(_range_capital_ratio / _per_position_ratio) if _per_position_ratio > 0 else 0
max_range_positions = max(0, min(settings.RANGE_TRADE_MAX_POSITIONS, _ratio_cap))
if max_range_positions <= 0:
logger.info("[RangeTrade] 자본 배분 상한이 0 — 스킵")
return []
_range_count_result = await db.execute(
select(ActiveTrade.stock_code).where(
ActiveTrade.status == "active",
ActiveTrade.strategy_name == RANGE_TRADE_STRATEGY_NAME,
)
)
_range_held = {row[0] for row in _range_count_result.fetchall()}
range_slots = max_range_positions - len(_range_held)
if range_slots <= 0:
logger.info(
"[RangeTrade] 레인지 포지션 한도 도달 (%d/%d) — 스킵",
len(_range_held), max_range_positions,
)
return []
remaining_slots = min(remaining_slots, range_slots)
# ── 게이트 6: MDD ────────────────────────────────────────────────────
if not await mdd_manager.can_open_new_position():
logger.warning("[RangeTrade] MDD 초과 — 신규 진입 차단")
return []
# ── 게이트 7: 적응형 Crisis 모드 (fail-closed) ────────────────────────
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp = get_current_adaptive_params()
_adp_crisis = (not _adp.fallback_used) and _adp.trading_mode == "crisis"
except Exception as e:
logger.warning(f"[RangeTrade] 적응형 파라미터 조회 실패 — 진입 중단(fail-closed): {e}")
return []
if _adp_crisis:
logger.warning("[RangeTrade] 적응형 CRISIS 모드 — 레인지 진입 차단")
return []
# ── 게이트 8: 플레이북 ───────────────────────────────────────────────
if not is_entry_allowed_by_playbook():
logger.info("[RangeTrade] 플레이북 차단 — 신규 진입 비활성화")
return []
# ── 후보 조회 ────────────────────────────────────────────────────────
held_result = await db.execute(select(Portfolio.stock_code).where(Portfolio.quantity > 0))
held_codes = {row[0] for row in held_result.fetchall()}
analysis_query = (
select(StockAnalysis)
.where(StockAnalysis.analysis_date >= date.today())
.order_by(desc(StockAnalysis.score))
.limit(30)
)
analysis_result = await db.execute(analysis_query)
candidates = analysis_result.scalars().all()
if not candidates:
logger.debug("[RangeTrade] 분석 데이터 없음 — 스킵")
return []
range_stocks: list[dict] = []
scan_count = 0
for stock in candidates:
if scan_count >= settings.RANGE_TRADE_SCAN_LIMIT:
break
if len(range_stocks) >= remaining_slots:
break
# 게이트 9: 중복 진입 방지
if stock.stock_code in held_codes:
continue
# 게이트 11: 가격 필터
if float(stock.current_price or 0) <= settings.MIN_PRICE_KRW:
continue
# 게이트 10: 연속손실 밴 (try 밖 — 실패를 삼키지 않는다)
if await loss_tracker.is_banned_safe(stock.stock_code):
logger.debug("[RangeTrade] %s 연속손실 밴 — 스킵", stock.stock_code)
continue
try:
chart = await korea_investment_api.get_daily_chart(stock.stock_code, limit=60)
output2 = chart.get("output2", [])
if not output2 or len(output2) < 40:
continue
bars = list(reversed(output2)) # 최신이 앞 → 오래된 순으로
opens = [float(b.get("stck_oprc", 0)) for b in bars]
closes = [float(b.get("stck_clpr", 0)) for b in bars]
highs = [float(b.get("stck_hgpr", 0)) for b in bars]
lows = [float(b.get("stck_lwpr", 0)) for b in bars]
volumes = [int(b.get("acml_vol", 0)) for b in bars]
if not closes or closes[-1] <= 0:
continue
scan_count += 1
current_close = closes[-1]
current_volume = volumes[-1] if volumes else 0
rsi = calculate_rsi(closes, period=14)
if rsi is None:
continue
rsi_prev = calculate_rsi(closes[:-RSI_DIRECTION_LOOKBACK], period=14)
bb = calculate_bollinger(closes, period=20)
if bb is None:
continue
bb_upper, bb_middle, bb_lower = bb
adx = calculate_adx(highs, lows, closes, period=14)
atr = calculate_atr(highs, lows, closes, period=14) or 0.0
signal = check_range_buy_signal(
highs=highs, lows=lows, closes=closes, volumes=volumes,
current_close=current_close, current_volume=current_volume,
rsi=rsi, bb_upper=bb_upper, bb_middle=bb_middle, bb_lower=bb_lower,
adx=adx, rsi_prev=rsi_prev, opens=opens,
)
if not signal:
await asyncio.sleep(0.3) # KIS API 레이트 리밋
continue
stops = calculate_range_stop_loss(
entry_price=current_close, atr=atr, box_low=signal["box_low"],
)
range_stocks.append({
"stock_code": stock.stock_code,
"stock_name": stock.stock_name,
"current_price": int(current_close),
"score": 5.0,
# signals: trade_executor 의 BUY_KEYWORDS/STRONG_BUY_KEYWORDS 매칭용.
# 레인지 진입은 'BB 하단 터치 + RSI 바닥 탈출 반등' 과 동일 성격이다.
"signals": [
BuySignal.볼린저하한선.value,
BuySignal.RSI바닥탈출.value,
BuySignal.횡보장평균회귀.value,
],
"signal": signal["description"],
"strategy_type": "swing", # 스윙 예산/청산 파이프라인 공유
"strategy_name": RANGE_TRADE_STRATEGY_NAME, # ActiveTrade.strategy_name
"entry_reason": f"[{RANGE_TRADE_STRATEGY_NAME}] {signal['description']}",
"stop_loss": stops["stop_loss_pct"], # 음수 보장 (-abs)
"take_profit": max(
0.0,
(signal["target_price"] - current_close) / current_close * 100.0,
),
"atr": atr,
"closes": closes,
"rsi": rsi,
"bb": (bb_upper, bb_middle, bb_lower),
})
logger.info(
"[RangeTrade] 박스권 진입 시그널: %s %s — %s",
stock.stock_code, stock.stock_name, signal["description"],
)
await asyncio.sleep(0.3)
except Exception as e:
logger.debug(f"[RangeTrade] {stock.stock_code} 스캔 실패: {e}")
continue
if not range_stocks:
logger.debug("[RangeTrade] 시그널 없음")
return []
range_stocks = range_stocks[:remaining_slots]
# ── 진입 실행 ────────────────────────────────────────────────────────
# execute_auto_trade_background() 는 SIDEWAYS 의 max_trades=0 에서 즉시 종료하므로
# 종목별 execute_auto_trade() 를 직접 호출한다(상단 설계 결정 주석 참조).
# _regime_str="bear": _execute_buy 의 sideways 하드 차단을 피하면서 가장 보수적인
# 자본 한도(BEAR 30%)를 적용받는다. _position_multiplier 로 종목당 비중을 2%로 축소.
results: list[dict] = []
for stock_dict in range_stocks:
stock_dict["_regime_str"] = "bear"
stock_dict["_position_multiplier"] = float(settings.RANGE_TRADE_POSITION_MULTIPLIER)
try:
result = await execute_auto_trade(stock_dict, db)
except Exception as e:
logger.error(
f"[RangeTrade] {stock_dict.get('stock_code')} 진입 실행 오류: {e}",
exc_info=True,
)
continue
if isinstance(result, dict) and result.get("action") == "BUY":
results.append(result)
else:
logger.info(
"[RangeTrade] %s 진입 보류: action=%s reason=%s",
stock_dict.get("stock_code"),
(result or {}).get("action") if isinstance(result, dict) else "?",
(result or {}).get("reason", "미상") if isinstance(result, dict) else "?",
)
await asyncio.sleep(0.5) # KIS API 레이트 리밋
if results:
logger.info(f"[RangeTrade] {len(results)}건 진입 실행 완료")
return results