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

@@ -183,7 +183,30 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
try:
stock_code = holding.stock_code
stock_name = holding.stock_name or stock_code
quantity = holding.quantity
# [H2 2026-09-27] Portfolio 행은 stock_code UNIQUE 라 같은 종목의
# 스윙+데이트레이딩 수량이 합산돼 있다. 행 전체를 청산 대상으로 잡으면
# DT 보유분까지 팔려 DT 인메모리와 불일치(주문거부/과다매도)가 생긴다.
# trade_executor._execute_sell 과 동일하게 DT 인메모리 수량을 차감한
# '스윙 보유분'만 대상으로 한다.
total_qty = int(holding.quantity or 0)
try:
from app.services.daytrading.position_manager import day_trading_state as _dts
_dt_pos = _dts.positions.get(holding.stock_code)
_dt_qty = int((_dt_pos or {}).get("quantity", 0) or 0)
except Exception:
_dt_qty = 0
quantity = max(0, total_qty - _dt_qty)
if quantity <= 0:
logger.info(
f"[SwingExit] {stock_code}: 보유 {total_qty}주 중 DT {_dt_qty}주 "
f"— 스윙 보유분 없음, 청산 스킵"
)
continue
if _dt_qty > 0:
logger.info(
f"[SwingExit] {stock_code}: 보유 {total_qty}주 중 DT {_dt_qty}주 제외 "
f"→ 스윙 {quantity}주 대상"
)
avg_price = float(holding.average_price or 0)
market = getattr(holding, "market", None) or (
"KRX" if _is_domestic(stock_code) else "NASD"
@@ -208,7 +231,15 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
active_trade = await get_swing_active_trade(db, stock_code)
# ActiveTrade upsert/close 시 항상 전략명을 명시해야 같은 종목의 DT 행을
# 잘못 건드리지 않는다(db_worker 는 전략명이 없으면 최신 행을 매칭).
# [2026-09-27 후속] strategy_name 이 NULL 인 legacy/동기화 유입 행을
# 'swing' 으로 폴백하면 DB 에 'swing' 행이 없어 매칭 0건 →
# 유령 active 행이 영구 잔존한다. trade_executor 와 동일하게
# payload 의 strategy_name_not=DT_STRATEGY_NAMES 로 '비-DT 최신 행'을
# 직접 겨냥한다(전략명이 있으면 기존 동작 그대로).
_at_strategy = (active_trade.strategy_name if active_trade else None) or "swing"
_at_filter_not_dt = bool(
active_trade is not None and not active_trade.strategy_name
)
remaining_pct = float(active_trade.remaining_pct) if active_trade and active_trade.remaining_pct is not None else 1.0
trailing_high = float(active_trade.highest_since_entry) if active_trade and active_trade.highest_since_entry else None
@@ -216,11 +247,16 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
if active_trade and latest_price > (trailing_high or 0):
trailing_high = latest_price
from app.services.db_worker import enqueue_active_trade_upsert
await enqueue_active_trade_upsert({
"stock_code": stock_code,
"strategy_name": _at_strategy, # DT 행 오염 방지
"highest_since_entry": trailing_high,
})
_th_payload = {"stock_code": stock_code}
if _at_filter_not_dt:
# 전략명 NULL 인 비-DT 행 대상 — 이름 동등 매칭 불가.
# strategy_name 과 동시에 넣으면 우선순위상 필터가 무력화된다.
from app.services.db_worker import DT_STRATEGY_NAMES as _DTN_TH
_th_payload["strategy_name_not"] = list(_DTN_TH)
else:
_th_payload["strategy_name"] = _at_strategy # DT 행 오염 방지
_th_payload["highest_since_entry"] = trailing_high
await enqueue_active_trade_upsert(_th_payload)
# ── 전략별 청산 분기 ────────────────────────────────────
exit_signal = None
@@ -303,13 +339,25 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
continue
# ── 매도 수량 결정 (소량 분할 방지) ─────────────────────
# [M5 2026-09-27] sell_pct 의 의미 = '진입 시 원본 수량 대비' 비율.
# check_exit_signal / _check_momentum_exit 의 remaining_pct 게이트가
# 1.0 → 0.70 → 0.40 (원본 대비 누적)을 가정하므로, 매도 수량도 반드시
# 원본 수량 기준으로 계산해야 remaining_pct 와 실제 잔량이 일치한다.
# (기존: sell_qty 는 '현재 잔량'×sell_pct, remaining_pct 는 '원본' 기준
# 차감 → 분할이 반복될수록 회계가 벌어졌다.)
# 원본 수량은 ActiveTrade 의 remaining_pct 로 역산한다.
sell_pct = getattr(exit_signal, "sell_pct", 1.0)
if sell_pct < 1.0 and quantity <= 3:
original_qty = quantity
if 0 < remaining_pct < 1.0:
original_qty = max(quantity, int(round(quantity / remaining_pct)))
if sell_pct >= 1.0:
sell_qty = quantity # 전량 매도(스윙 보유분 전체)
elif quantity <= 3:
# 3주 이하: 분할 매도 비효율 → 전량 매도
sell_qty = quantity
logger.info(f"[SwingExit] {stock_code}: {quantity}주 소량 → 전량매도 (분할 비효율)")
else:
sell_qty = min(max(int(quantity * sell_pct), 1), quantity)
sell_qty = min(max(int(original_qty * sell_pct), 1), quantity)
if sell_qty <= 0:
continue
@@ -360,28 +408,59 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
# 포트폴리오 화면 즉시 반영 (Portfolio 수량 차감 + 캐시)
from app.services.portfolio_cache_bridge import reflect_sell
await reflect_sell(stock_code, sell_qty, latest_price, remaining_qty=quantity - sell_qty)
# remaining_qty 는 Portfolio 행(스윙+DT 합산) 기준이어야 한다 —
# 스윙 보유분(quantity)만 쓰면 DT 수량이 사라진다.
await reflect_sell(
stock_code, sell_qty, latest_price, remaining_qty=total_qty - sell_qty
)
# 전량 청산이면 ActiveTrade 종료, 분할 매도면 remaining_pct 갱신
if sell_qty >= quantity:
# strategy_name 을 비우면 db_worker 가 '가장 최근 active 행'을 닫는다
# → 같은 종목에 DT 행이 있으면 그쪽이 닫히고 스윙 행은 영구 잔존.
await enqueue_close_active_trade(
stock_code, strategy_name=_strategy or "swing"
)
if _at_filter_not_dt:
# 전략명 NULL 인 비-DT 행은 이름으로 닫을 수 없다 →
# strategy_name_not 필터로 비-DT 최신 행을 직접 겨냥.
from app.services.db_worker import (
DT_STRATEGY_NAMES as _DTN_CL,
enqueue_db_write,
)
await enqueue_db_write({
"type": "close_active_trade",
"data": {
"stock_code": stock_code,
"strategy_name_not": list(_DTN_CL),
},
})
else:
await enqueue_close_active_trade(
stock_code, strategy_name=_strategy or "swing"
)
else:
# 분할 매도 후 remaining_pct 갱신 (반복 트리거 방지)
new_remaining = max(0.0, remaining_pct - sell_pct)
# [M5] 실제 잔량 기준으로 재계산 — sell_pct 단순 차감은 sell_qty 가
# 원본 기준일 때만 일치하므로, 반올림 오차까지 흡수하도록 잔량/원본으로 계산.
if original_qty > 0:
new_remaining = max(0.0, min(1.0, (quantity - sell_qty) / original_qty))
else:
new_remaining = max(0.0, remaining_pct - sell_pct)
from app.services.db_worker import enqueue_active_trade_upsert
_at_data = {
"stock_code": stock_code,
"strategy_name": _strategy or "swing",
_at_data = {"stock_code": stock_code}
if _at_filter_not_dt:
# active_trade 가 존재하는데 전략명이 NULL 인 경우에만 True →
# 아래 `active_trade is None` 신규생성 분기와 상호배타적이므로
# strategy_name 과 strategy_name_not 이 동시에 실리지 않는다.
from app.services.db_worker import DT_STRATEGY_NAMES as _DTN_UP
_at_data["strategy_name_not"] = list(_DTN_UP)
else:
_at_data["strategy_name"] = _strategy or "swing"
_at_data.update({
"remaining_pct": new_remaining,
# 잔량도 함께 갱신 — 누락 시 재기동 복원이 원래 수량으로 되살아나
# 보유량보다 많이 매도한다(2026-09-23 DT LG씨엔에스 사례와 동일 패턴)
"quantity": quantity - sell_qty,
"highest_since_entry": latest_price if latest_price > (trailing_high or 0) else trailing_high,
}
})
if active_trade is None:
# [FIX] ActiveTrade 행이 없는 포지션(동기화 유입 등)은 upsert가
# NOT NULL(quantity/entry_price) 위반으로 INSERT 실패 → remaining_pct