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

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

View File

@@ -17,6 +17,9 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.models import Portfolio, ActiveTrade
# [§8.5 항목3] DT 제외 필터는 db_worker 를 정본으로 공유한다.
# (db_worker 는 서비스 모듈을 모듈 수준에서 import 하지 않으므로 순환 참조 없음)
from app.services.db_worker import DT_STRATEGY_NAMES, not_dt_strategy_clause # noqa: F401
logger = logging.getLogger(__name__)
@@ -47,9 +50,13 @@ async def get_swing_holdings(db: AsyncSession) -> list:
async def get_swing_active_trades(db: AsyncSession) -> list:
"""활성 스윙 ActiveTrade 목록 반환.
strategy_name != 'day_trade' 조건으로 데이트레이딩 포지션 제외.
DT 계열(DT_STRATEGY_NAMES) 제외 조건으로 데이트레이딩 포지션만 걸러낸다.
분할 청산 진행 상태(remaining_pct), 최고가(highest_since_entry) 포함.
[2026-09-27 §8.5 항목3] 기존 `strategy_name != "day_trade"` 는
SQL 3값 논리로 strategy_name IS NULL 인 legacy 스윙 행까지 함께 제외했고,
'daytrading' 전략명은 반대로 통과시켰다 → not_dt_strategy_clause() 로 통일.
Args:
db: AsyncSession 인스턴스
@@ -59,7 +66,7 @@ async def get_swing_active_trades(db: AsyncSession) -> list:
result = await db.execute(
select(ActiveTrade).where(
ActiveTrade.status == "active",
ActiveTrade.strategy_name != "day_trade",
not_dt_strategy_clause(ActiveTrade.strategy_name),
)
)
return result.scalars().all()
@@ -81,12 +88,14 @@ async def get_swing_active_trade(db: AsyncSession, stock_code: str) -> ActiveTra
# [2026-09-23] scalar_one_or_none() 은 행이 2개 이상이면 MultipleResultsFound 를 던지고,
# 호출측 except 가 이를 삼켜 해당 종목의 청산(손절 포함)이 통째로 스킵됐다.
# db_worker legacy 경로와 동일하게 가장 최근 행을 사용한다.
# [2026-09-27 §8.5 항목3] strategy_name IS NULL 인 legacy 스윙 행이
# `!= "day_trade"` 로 탈락하던 문제 + 'daytrading' 누락을 동시에 수정.
result = await db.execute(
select(ActiveTrade)
.where(
ActiveTrade.stock_code == stock_code,
ActiveTrade.status == "active",
ActiveTrade.strategy_name != "day_trade",
not_dt_strategy_clause(ActiveTrade.strategy_name),
)
.order_by(ActiveTrade.id.desc())
.limit(1)

View File

@@ -35,6 +35,27 @@ from app.services.swing.entry_service import execute_swing_entries, scan_dual_st
logger = logging.getLogger(__name__)
_KST = pytz.timezone("Asia/Seoul")
# [M2] MDD 갱신 실패 감시 — 연속 실패가 이 횟수를 넘으면 error 로 승격
MDD_UPDATE_FAILURE_ALERT = 5
# MDD 갱신 상태 (모니터링/디버깅용 인메모리 상태)
_mdd_health: dict = {"consecutive_failures": 0, "last_error": None, "empty_account": False}
def get_mdd_update_health() -> dict:
"""MDD 갱신 상태 스냅샷 반환 (연속 실패 횟수/마지막 오류/진입 차단 여부).
swing_scheduler 의 MDD 갱신이 조용히 실패 중인지 외부에서 확인하기 위한 읽기 전용 헬퍼.
[항목7 2026-09-27] 헬스의 원본(source of truth)은 mdd_manager 로 이동했다
(can_open_new_position() 이 순환 import 없이 참조하려면 mdd_manager 가 소유해야 함).
이 함수는 시그니처를 유지한 읽기 전용 파사드이며, mdd_manager 스냅샷을 병합해
blind_block_active/threshold 까지 함께 노출한다.
"""
snapshot = dict(_mdd_health)
snapshot.update(mdd_manager.get_update_health())
return snapshot
def _normalize_recommendations(rec: dict) -> dict:
"""recommendations_by_rule 키 정규화.
@@ -93,6 +114,10 @@ async def swing_scheduler() -> None:
from app.core.redis import get_json
logger.info("📈 [Swing] Scheduler started (every 60s)")
_mdd_update_failures = 0
_mdd_last_error: str | None = None
# [M3] '정상 응답이지만 총평가금액 0'(빈/미입금 계좌) 누적 횟수 — 장애 카운터와 분리
_mdd_empty_account_seen = 0
try:
while True:
try:
@@ -117,22 +142,91 @@ async def swing_scheduler() -> None:
# ── Phase 2: MDD 업데이트 + 체크 + 강제 축소 ──
# [FIX] mdd_manager.update()를 매 사이클 호출하여 MDD 감지 활성화
# [M2 2026-09-27] 여기가 mdd_manager.update() 의 유일한 실효 호출부다.
# 실패를 debug 로 삼키면 KIS 장애 시 peak/drawdown 이 갱신되지 않아
# -10%/-20% 게이트가 무증상으로 영구 미발동한다 → 경고 + 연속 실패 카운트.
# [M3 2026-09-27] "응답 없음/파싱 실패"(진짜 장애)와 "정상 응답 + 총평가금액
# 0"(빈/미입금 계좌)을 구분한다. 후자는 정상 응답이므로 실패로 계수하지 않고
# MDD 갱신만 스킵한다 — 그러지 않으면 신규 모의계좌에서 매 사이클 실패가
# 누적돼 🚨 error 로그가 계속 찍혀 실제 KIS 장애와 구분되지 않는다.
_empty_account = False
try:
from app.services.korea_investment_api import korea_investment_api
balance_resp = await korea_investment_api.get_balance()
if balance_resp:
_total = float((balance_resp.get("output2") or [{}])[0].get("tot_evlu_amt", 0))
if _total > 0:
mdd_result = await mdd_manager.update(_total)
if mdd_result.get("action") != "NONE":
logger.warning(f"[Swing] MDD 상태 변경: {mdd_result}")
if not balance_resp:
raise ValueError("잔고 조회 응답 없음 (resp=없음)")
_out2 = balance_resp.get("output2")
if not isinstance(_out2, list) or not _out2:
raise ValueError("잔고 응답 파싱 실패 (output2 누락/형식 이상)")
_raw_total = (_out2[0] or {}).get("tot_evlu_amt")
if _raw_total is None or str(_raw_total).strip() == "":
raise ValueError("잔고 응답 파싱 실패 (tot_evlu_amt 누락)")
try:
_total = float(_raw_total)
except (TypeError, ValueError):
raise ValueError(f"잔고 응답 파싱 실패 (tot_evlu_amt={_raw_total!r})")
if _total <= 0:
# 정상 응답이지만 자산 0 — 빈 계좌/미입금. 장애 아님.
_empty_account = True
_mdd_empty_account_seen += 1
if _mdd_empty_account_seen == 1 or _mdd_empty_account_seen % 60 == 0:
logger.info(
f"[Swing] 잔고 총평가금액 0 (빈/미입금 계좌) — MDD 갱신 스킵 "
f"({_mdd_empty_account_seen}회 누적)"
)
else:
_mdd_empty_account_seen = 0
if _empty_account:
# 정상 응답(장애 아님) — 실패 카운터 리셋 + 빈 계좌 표시.
# 빈 계좌는 진입 차단 사유가 아니다(항목7).
await mdd_manager.record_update_success(empty_account=True)
if _total > 0:
mdd_result = await mdd_manager.update(_total)
# [항목7] 갱신 성공 → fail-safe 차단 해제
await mdd_manager.record_update_success(empty_account=False)
if _mdd_update_failures:
logger.warning(
f"[Swing] MDD 갱신 복구 (연속 실패 {_mdd_update_failures}회 후 정상화)"
)
_mdd_update_failures = 0
_mdd_last_error = None
if mdd_result.get("action") != "NONE":
logger.warning(f"[Swing] MDD 상태 변경: {mdd_result}")
except Exception as e:
logger.debug(f"[Swing] MDD update 조회 실패 (무시): {e}")
_mdd_update_failures += 1
_mdd_last_error = str(e)
# [항목7] 실패를 mdd_manager 에 보고 → 임계 도달 시
# can_open_new_position() 이 모든 진입 경로를 차단한다(청산은 무영향).
await mdd_manager.record_update_failure(_mdd_last_error)
if _mdd_update_failures >= MDD_UPDATE_FAILURE_ALERT:
logger.error(
f"[Swing] 🚨 MDD 갱신 연속 실패 {_mdd_update_failures}회 — "
f"peak/drawdown 미갱신 상태, -10%/-20% 게이트가 작동하지 않을 수 있음: {e}",
exc_info=True,
)
else:
logger.warning(
f"[Swing] MDD 갱신 실패 ({_mdd_update_failures}회 연속): {e}"
)
# 상태 노출용 (모니터링/헬스체크에서 참조 가능)
_mdd_health["consecutive_failures"] = _mdd_update_failures
_mdd_health["last_error"] = _mdd_last_error
else:
_mdd_health["consecutive_failures"] = _mdd_update_failures
_mdd_health["last_error"] = _mdd_last_error
_mdd_health["empty_account"] = _empty_account
can_open_new = await mdd_manager.can_open_new_position()
logger.info(f"[Swing] MDD 체크: can_open={can_open_new}")
if not can_open_new:
logger.warning("[Swing] MDD 초과 — 신규 진입 차단")
if mdd_manager.get_update_health().get("blind_block_active"):
logger.error(
f"[Swing] 🚨 MDD 갱신 장애로 신규 진입 차단(fail-safe, 연속 실패 "
f"{_mdd_update_failures}회, 마지막 오류: {_mdd_last_error}) — "
f"청산/축소는 계속 수행"
)
else:
logger.warning("[Swing] MDD 초과 — 신규 진입 차단")
if mdd_manager.reduction_applied:
try:
reduce_pct = 0.50 if mdd_manager.reduction_applied == "20pct" else 0.30
@@ -245,6 +339,10 @@ async def swing_scheduler() -> None:
# 6. 스윙 진입 실행 (별도 세션)
async with AsyncSessionLocal() as entry_session:
logger.info(f"[Swing] 진입 실행 시작: {top_stocks[:5]}")
# execute_swing_entries() 내부에서 진입/청산/실패 건수를 이미
# 분리 집계해 로깅하므로(entry_service.py 참조), 여기서는 결과를
# 그대로 소비하지 않고 반환값(list, 2026-09-27 이후 항상 list)만
# 확인한다.
await execute_swing_entries(rec, entry_session)
# 7. 듀얼 전략 시그널 스캔 (모멘텀 돌파 + 눌림목 매수, CLAUDE.md 8.4)
@@ -252,7 +350,29 @@ async def swing_scheduler() -> None:
async with AsyncSessionLocal() as dual_session:
dual_results = await scan_dual_strategy_signals(dual_session)
if dual_results:
logger.info(f"[Swing] 듀얼 전략 진입: {len(dual_results)}건")
# [2026-09-27] scan_dual_strategy_signals() 가 위임하는
# execute_auto_trade_background() 는 항상 list 를 반환하며,
# 그 list 에는 성공 BUY 뿐 아니라 주문 실패(success=False)한
# BUY/SELL, 청산 우선 로직으로 섞인 SELL 도 포함된다.
# 따라서 len(dual_results) 를 그대로 "진입 n건"으로 쓰지 않고
# action/success 기준으로 분리 집계한다(상세 집계는
# entry_service.scan_dual_strategy_signals() 내부 로그 참조).
buy_success = sum(
1 for r in dual_results
if isinstance(r, dict) and r.get("action") == "BUY" and r.get("success")
)
sell_count = sum(
1 for r in dual_results
if isinstance(r, dict) and r.get("action") == "SELL"
)
fail_count = sum(
1 for r in dual_results
if isinstance(r, dict) and not r.get("success")
)
logger.info(
f"[Swing] 듀얼 전략 — 진입 {buy_success}건 / "
f"청산 {sell_count}건 / 주문실패 {fail_count}건"
)
except Exception as e:
logger.debug(f"[Swing] 듀얼 전략 스캔 오류: {e}")