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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user