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

@@ -10,7 +10,11 @@ import json
import logging
from typing import Dict, Optional
from app.core.config import settings
from app.core.json_helpers import _dumps
# [2026-09-27 §8.5 항목6] DT 전략명 정본은 db_worker (순환 import 없음 — db_worker 는
# 모듈 레벨에서 서비스 모듈을 import 하지 않는다).
from app.services.db_worker import DT_STRATEGY_NAMES as _DT_STRATEGY_NAMES
logger = logging.getLogger(__name__)
@@ -28,6 +32,15 @@ class MDDManager:
self._reduction_executed_10pct: bool = False # 중복 축소 실행 방지
self._reduction_executed_20pct: bool = False
self._lock = asyncio.Lock() # 스케줄러-sync 동시 접근 방지
# ── [항목7 2026-09-27] MDD 갱신 헬스 (fail-safe 판정용) ──────────────
# 갱신 경로(swing/scheduler)가 record_update_failure/success 로 여기에 보고한다.
# 상태를 mdd_manager 가 소유해야 can_open_new_position() 이 순환 import 없이
# 판정할 수 있다(scheduler → mdd_manager 는 모듈 레벨 import 이므로 역방향 금지).
self._update_failures: int = 0
self._update_last_error: Optional[str] = None
self._update_empty_account: bool = False
self._blind_block_active: bool = False
self._blind_block_hits: int = 0 # 차단 발동 누적 횟수(로그 레이트 리밋용)
async def save_to_redis(self) -> None:
"""MDD 상태를 Redis에 영속화 — 앱 재시작 시 복원용."""
@@ -186,9 +199,82 @@ class MDDManager:
logger.warning(f"[MDD v2] Redis 복원 실패 (user={user_id}): {e}")
return False
async def can_open_new_position(self) -> bool:
"""신규 포지션 진입 가능 여부 (thread-safe)"""
# ── [항목7] MDD 갱신 헬스 보고/조회 ────────────────────────────────────────
# 기존 시그니처 변경 없이 새 메서드로만 추가한다.
async def record_update_failure(self, error: str) -> int:
"""MDD 갱신 실패를 기록하고 누적 연속 실패 횟수를 반환.
갱신 경로(swing/scheduler)의 except 절에서 호출한다.
여기서 누적된 횟수가 MDD_BLIND_FAILURE_THRESHOLD 이상이면
can_open_new_position() 이 신규 진입을 차단한다(청산은 영향 없음).
"""
async with self._lock:
self._update_failures += 1
self._update_last_error = str(error)
self._update_empty_account = False
return self._update_failures
async def record_update_success(self, empty_account: bool = False) -> None:
"""MDD 갱신 성공(또는 '정상 응답 + 빈 계좌')을 기록해 실패 카운터를 리셋.
empty_account=True 는 tot_evlu_amt<=0(미입금 계좌)로 갱신을 스킵한 경우다.
장애가 아니므로 실패로 계수하지 않고, 진입 차단 사유도 되지 않는다.
"""
async with self._lock:
self._update_failures = 0
self._update_last_error = None
self._update_empty_account = bool(empty_account)
self._blind_block_active = False
self._blind_block_hits = 0
def _is_update_blind(self) -> bool:
"""MDD 갱신이 '눈이 먼' 상태인지 판정 (Lock 보유 중 호출, DB/IO 없음).
- 빈 계좌(empty_account)는 차단 사유 아님 — 자산 0이면 애초에 매수 불가이고
입금 직후 첫 사이클을 막을 이유가 없다.
- 설정으로 끌 수 있으나 기본값은 True(config.MDD_BLIND_BLOCK_ENTRY).
"""
if not settings.MDD_BLIND_BLOCK_ENTRY:
return False
if self._update_empty_account:
return False
threshold = max(1, int(settings.MDD_BLIND_FAILURE_THRESHOLD))
return self._update_failures >= threshold
def get_update_health(self) -> dict:
"""MDD 갱신 헬스 스냅샷 (동기·읽기 전용). 차단 여부 관측용."""
return {
"consecutive_failures": self._update_failures,
"last_error": self._update_last_error,
"empty_account": self._update_empty_account,
"blind_block_enabled": bool(settings.MDD_BLIND_BLOCK_ENTRY),
"blind_block_threshold": int(settings.MDD_BLIND_FAILURE_THRESHOLD),
"blind_block_active": self._blind_block_active,
"blind_block_hits": self._blind_block_hits,
}
async def can_open_new_position(self) -> bool:
"""신규 포지션 진입 가능 여부 (thread-safe)
[항목7 2026-09-27] MDD 갱신이 연속 실패 중이면(= -10%/-20% 게이트가 작동
불가) 신규 진입을 차단한다. 모든 진입 경로가 이 메서드를 await 하므로
여기서 한 번에 적용된다. 시그니처 불변, 청산 경로에는 영향 없음.
"""
async with self._lock:
blind = self._is_update_blind()
self._blind_block_active = blind
if blind:
self._blind_block_hits += 1
if self._blind_block_hits == 1 or self._blind_block_hits % 20 == 0:
logger.error(
f"[MDD] 🚨 신규 진입 차단(fail-safe): MDD 갱신 연속 실패 "
f"{self._update_failures}회 (임계 {settings.MDD_BLIND_FAILURE_THRESHOLD}) — "
f"peak/drawdown 미갱신으로 -10%/-20% 게이트가 작동하지 않음. "
f"마지막 오류: {self._update_last_error} "
f"(누적 차단 {self._blind_block_hits}회, 청산은 정상 동작)"
)
return False
return not self.is_paused
async def is_short_term_strategy_enabled(self) -> bool:
@@ -197,14 +283,31 @@ class MDDManager:
return not self.is_short_term_strategy_paused
async def reset(self):
"""MDD 상태 리셋 (수동 복구 시) — async + Lock으로 동시성 안전 보장"""
"""MDD 상태 리셋 (수동 복구 시) — async + Lock으로 동시성 안전 보장
[2026-09-27 fail-safe 해제 누락 수정] MDD 갱신 연속 실패로 인한
fail-safe 차단(can_open_new_position() 이 False 를 반환하는 상태)은
기존에는 여기서 초기화되지 않아, 운영자가 reset()을 호출해도 KIS 장애가
스스로 해소될 때까지 신규 진입이 계속 막혀 있었다. 호출부(관리자용
수동 초기화 엔드포인트, "신규 진입 차단 해제"가 목적)의 의도에 맞춰
fail-safe 카운터/플래그도 함께 초기화한다. peak_balance/drawdown 관련
상태는 기존 그대로 건드리지 않는다.
"""
async with self._lock:
self.is_paused = False
self.is_short_term_strategy_paused = False
self.reduction_applied = None
self._reduction_executed_10pct = False
self._reduction_executed_20pct = False
was_blind = self._blind_block_active or self._update_failures > 0
self._update_failures = 0
self._update_last_error = None
self._update_empty_account = False
self._blind_block_active = False
self._blind_block_hits = 0
logger.info("MDD 관리자 리셋")
if was_blind:
logger.info("[MDD] fail-safe 차단 해제 — 갱신 실패 카운터/차단 플래그 초기화됨")
await self.save_to_redis()
@@ -313,24 +416,16 @@ class MDDManager:
"strategy_type": "mdd_reduction",
})
# [FIX] 전량 매도 시 ActiveTrade 닫기
# [FIX] 전량 매도 시 ActiveTrade 닫기 / 부분 매도 시 잔량 갱신
# [M8 2026-09-27] 전략명을 비우면 db_worker 가 '가장 최근 active 행'을
# 매칭하므로 같은 종목에 DT 행이 있으면 DT 행이 닫히고 스윙 행이 영구 잔존한다.
# → DT 인메모리 포지션이 있는 종목은 전략명을 명시해 처리한다.
remaining_qty = quantity - sell_qty
if remaining_qty <= 0:
from app.services.db_worker import enqueue_close_active_trade
await enqueue_close_active_trade(stock_code)
else:
# 부분 매도: remaining_pct 갱신
# 기존 ActiveTrade의 remaining_pct에 매도 비율을 곱해야 함
# (예: 기존 0.5 * 남은비율 0.7 = 0.35)
# db_worker가 직접 setattr하므로, reduce_ratio를 전달하여
# 워커가 기존 값에 곱하도록 처리
from app.services.db_worker import enqueue_active_trade_upsert
sell_ratio = remaining_qty / quantity # 남은 비율 (0.7 or 0.5)
await enqueue_active_trade_upsert({
"stock_code": stock_code,
"remaining_pct_multiply": sell_ratio,
"quantity": remaining_qty, # 잔량 갱신(미갱신 시 다음 청산이 과다매도)
})
await _sync_active_trade_after_reduction(
stock_code=stock_code,
quantity=quantity,
remaining_qty=remaining_qty,
)
# [2026-09-23] 축소 체결분을 Portfolio·DT 인메모리에도 반영.
# 누락 시 다음 청산이 축소 전 수량으로 매도한다(과다매도).
@@ -486,17 +581,14 @@ class MDDManager:
"strategy_type": "mdd_reduction",
})
# [M8 2026-09-27] v1 과 동일 — DT 행 오폐기 방지 (전략명 명시)
remaining_qty = quantity - sell_qty
if remaining_qty <= 0:
await enqueue_close_active_trade_v2(user_id, stock_code, strategy_name=None)
else:
sell_ratio = remaining_qty / quantity
await enqueue_active_trade_upsert({
"user_id": user_id,
"stock_code": stock_code,
"remaining_pct_multiply": sell_ratio,
"quantity": remaining_qty,
})
await _sync_active_trade_after_reduction(
stock_code=stock_code,
quantity=quantity,
remaining_qty=remaining_qty,
user_id=user_id,
)
# [2026-09-23] 축소 체결분을 Portfolio·DT 인메모리에도 반영.
# 누락 시 다음 청산이 축소 전 수량으로 매도한다(과다매도).
@@ -542,6 +634,210 @@ class MDDManager:
mdd_manager = MDDManager()
# [M8] 비-데이트레이딩(스윙 계열) ActiveTrade.strategy_name 값 전수 목록
# db_worker 는 strategy_name 동등 비교만 지원하므로 '!= day_trade' 를 표현할 수 없다.
# → 비-DT 후보 전략명을 개별로 지정해 DT 행을 절대 건드리지 않는다.
#
# ⚠️ 경고: 이 목록은 `ActiveTrade.strategy_name` 에 기록되는 **모든 비-DT 값**을 덮어야
# 한다. 하나라도 빠지면 MDD 축소가 그 행에 반영되지 않아(db_worker 가 '필수 키 누락'
# 으로 스킵) quantity/remaining_pct 가 축소 전 값으로 남고, 다음 청산이 과다매도 →
# 주문거부를 일으킨다. 전량 축소 경로에서는 해당 행이 영구히 status='active' 로 남는다.
#
# [조사 방법 — 전략명 추가/변경 시 반드시 재실행]
# 1) grep -rn 'strategy_name' backend/app --include='*.py' \
# | grep -E "strategy_name(\"|')?\]?\s*[:=]"
# → enqueue_active_trade_upsert / ActiveTrade(...) 로 들어가는 리터럴 수집
# 2) grep -rn 'strategy_type' backend/app --include='*.py' | grep -E "strategy_type.*[:=]"
# → trade_executor 는 `stock.get("strategy_name") or stock.get("strategy_type","swing")`,
# portfolio 동기화는 `strategy_name=_h.strategy_type or "swing"` 이므로
# Portfolio.strategy_type 값도 그대로 ActiveTrade.strategy_name 이 된다.
# 3) 수집값에서 DT 계열(DT_STRATEGY_NAMES)만 제외하고 나머지 전부를 여기에 넣는다.
#
# 2026-09-27 조사 결과:
# "swing" — trade_executor.py:1105 기본값 / swing/entry_service.py:288
# / portfolio.py:873, :1317
# "MOMENTUM_BREAKOUT" — swing/entry_service.py:291
# "PULLBACK_BUY" — swing/entry_service.py:313
# "manual" — manual_buy.py:156(+:168), portfolio_v2.py:382(+:402),
# portfolio.py:1536 → Portfolio.strategy_type='manual' 경유
# "us_swing" — us_market_scheduler.py:374, :577 (Portfolio.strategy_type)
# "us_mean_reversion" — us_market_scheduler.py:306, :406 (ActiveTrade 직접)
# "synced" — portfolio.py sync_portfolio 외부 유입 포지션 시딩(§8.5 항목4③)
#
# [2026-09-27 §8.5 항목6] 아래 목록은 **참조/문서용**으로만 남는다.
# 축소 반영 경로는 열거 대신 payload 의 `strategy_name_not=DT_STRATEGY_NAMES`
# (= strategy_name IS NULL OR NOT IN (DT)) 한 건으로 처리하므로, 목록에서 빠진
# 전략명이 있어도 누락되지 않는다(NULL·미등재 전략명까지 자동 포함).
SWING_STRATEGY_NAMES = (
"swing",
"MOMENTUM_BREAKOUT",
"PULLBACK_BUY",
"manual",
"us_swing",
"us_mean_reversion",
"synced",
)
# 데이트레이딩 계열 ActiveTrade.strategy_name 값 — 정본은 db_worker.
# "day_trade" — daytrading/entry_service.py, exit_service.py
# "daytrading" — Portfolio.strategy_type='daytrading' 이 동기화로 그대로 들어가는 경우
# [2026-09-27 §8.5 항목6] 이중 정의 제거 — 정본은 db_worker.DT_STRATEGY_NAMES
# (모듈 상단에서 import, not_dt_strategy_clause 와 동일 목록 공유).
# db_worker 는 모듈 레벨에서 어떤 서비스 모듈도 import 하지 않으므로
# (asyncio/sqlalchemy/core.database/models.models 뿐) 순환 참조가 없다.
# 재노출: 기존 `from app.services.mdd_manager import DT_STRATEGY_NAMES` 호출부 호환 유지.
DT_STRATEGY_NAMES = _DT_STRATEGY_NAMES
def _dt_position_qty(stock_code: str) -> int:
"""데이트레이딩 인메모리 포지션 수량 (조회 실패 시 0). DB 접근 없음."""
try:
from app.services.daytrading.position_manager import day_trading_state
pos = day_trading_state.positions.get(stock_code)
return int((pos or {}).get("quantity", 0) or 0)
except Exception:
return 0
async def _sync_active_trade_after_reduction(
stock_code: str,
quantity: int,
remaining_qty: int,
user_id: Optional[int] = None,
) -> None:
"""MDD 축소 체결을 ActiveTrade 에 반영 (전략 귀속을 명시).
db_worker 의 close_active_trade / upsert_active_trade 는 strategy_name 이
비어 있으면 '가장 최근 active 행'을 매칭한다. 같은 종목에 데이트레이딩 행이
있으면 DT 행이 닫히거나 잔량이 덮어써지고 스윙 행은 영구 잔존한다.
처리 규칙:
- DT 인메모리 포지션 없음 → 기존 동작 유지(전략명 없이 최신 행 1건)
- DT 인메모리 포지션 있음 →
* 전량 축소: 비-DT 행 + DT 행을 각각 닫음(모두 청산됐으므로)
* 부분 축소: 비-DT 행만 갱신 (DT 잔량은 DT 자체 청산 경로가 갱신)
[2026-09-27 §8.5 항목6] 전략명 열거(SWING_STRATEGY_NAMES 루프) → payload 필터
1건으로 대체. 열거 방식은 (a) 목록에 없는/NULL 전략명 행을 놓치고,
(b) 미존재 전략명마다 db_worker 의 '필수 키 누락' 경고 로그를 남겼다.
- 비-DT 대상: strategy_name_not=DT_STRATEGY_NAMES (NULL 포함, DT 만 제외)
- DT 대상 : strategy_names=DT_STRATEGY_NAMES (IN)
시그니처 변경 없이 기존 enqueue_* / enqueue_db_write 인자 범위 내에서만 처리한다.
"""
from app.services.db_worker import (
enqueue_active_trade_upsert,
enqueue_close_active_trade,
enqueue_close_active_trade_v2,
enqueue_db_write,
)
dt_qty = _dt_position_qty(stock_code)
async def _close(strategy_name: Optional[str]) -> None:
if user_id is not None:
await enqueue_close_active_trade_v2(user_id, stock_code, strategy_name=strategy_name)
else:
await enqueue_close_active_trade(stock_code, strategy_name=strategy_name)
async def _close_filtered(**filters) -> None:
"""전략명 확장 필터(strategy_names / strategy_name_not)로 close 태스크 큐잉.
enqueue_close_active_trade 시그니처는 strategy_name 만 받으므로
(시그니처 불변 원칙) 범용 enqueue_db_write 로 payload 를 직접 넘긴다.
"""
data: dict = {"stock_code": stock_code, **filters}
if user_id is not None:
data["user_id"] = user_id
await enqueue_db_write({"type": "close_active_trade", "data": data})
async def _upsert(strategy_name: Optional[str], ratio: float, qty: int) -> None:
payload: dict = {
"stock_code": stock_code,
"remaining_pct_multiply": ratio,
# 잔량 갱신(미갱신 시 다음 청산이 축소 전 수량으로 과다매도)
"quantity": qty,
}
if strategy_name:
payload["strategy_name"] = strategy_name
if user_id is not None:
payload["user_id"] = user_id
await enqueue_active_trade_upsert(payload)
async def _upsert_non_dt(ratio: float, qty: int) -> None:
"""비-DT(스윙 계열) 최신 active 행 1건만 잔량 갱신. DT 행은 절대 건드리지 않음."""
payload: dict = {
"stock_code": stock_code,
"remaining_pct_multiply": ratio,
"quantity": qty,
"strategy_name_not": DT_STRATEGY_NAMES,
}
if user_id is not None:
payload["user_id"] = user_id
await enqueue_active_trade_upsert(payload)
if remaining_qty <= 0:
if dt_qty > 0:
# 전량 청산 — 비-DT 행과 DT 행을 모두 닫는다(대상 범위 불변).
await _close_filtered(strategy_name_not=DT_STRATEGY_NAMES)
await _close_filtered(strategy_names=list(DT_STRATEGY_NAMES))
logger.warning(
f"[MDD] {stock_code}: DT {dt_qty}주 병존 — 비-DT/DT ActiveTrade 각각 종료"
)
else:
await _close(None)
return
# 부분 축소: 남은 비율을 기존 remaining_pct 에 곱한다 (예: 기존 0.5 × 0.7 = 0.35)
sell_ratio = (remaining_qty / quantity) if quantity > 0 else 1.0
if dt_qty > 0:
# 스윙 행의 잔량 = 전체 잔량 − DT 잔량 (DT 는 체결분만큼 인메모리에서 차감됨)
_sold = max(0, quantity - remaining_qty)
_dt_left = max(0, dt_qty - _sold)
_swing_left = max(0, remaining_qty - _dt_left)
# [M1 2026-09-27] 배율은 반드시 '스윙 수량 기준' 으로 계산한다.
# 총수량(스윙+DT) 기준 sell_ratio 를 곱하면 스윙 행의 remaining_pct 가
# 과소평가되어(예: 정답 0.875 대신 0.70) _check_momentum_exit 의
# 1차 익절 게이트(remaining_pct > 0.75)가 영구 미발동한다.
_swing_entry_qty = max(0, quantity - dt_qty)
if _swing_entry_qty > 0:
_swing_ratio = min(1.0, max(0.0, _swing_left / _swing_entry_qty))
else:
_swing_ratio = 1.0
if _swing_left <= 0:
# [M1-guard 2026-09-27] _close() 는 '관리 대상에서 제거'라는 과도한 부작용이
# 있으므로 인메모리 DT 수량을 신뢰할 수 있을 때만 허용한다.
# quantity 는 KIS 잔고(hldg_qty), dt_qty 는 인메모리 → dt_qty > quantity 면
# 인메모리가 잔고보다 큼 = stale 확정(외부 경로로 DT 분이 이미 매도된 경우).
# 이때 _swing_left 는 허위 0 이므로 close 하면 실제 보유 중인 스윙 행이
# status='closed' 가 되어 손절·트레일링 관리가 사라진다.
if dt_qty > quantity:
# 보수적 처리: 잔고 전량을 스윙분으로 간주하고 잔량만 갱신(close 금지).
# 아무것도 안 하면 quantity 가 축소 전 값으로 남아 다음 청산이 과다매도한다.
await _upsert_non_dt(sell_ratio, remaining_qty)
logger.warning(
f"[MDD] {stock_code}: DT 인메모리 수량이 잔고를 초과 — stale 판단 "
f"(잔고 quantity={quantity}, 인메모리 dt_qty={dt_qty}, 매도 {_sold}주) → "
f"스윙 ActiveTrade close 생략, 잔량 {remaining_qty}주·배율 {sell_ratio:.4f} 로 갱신"
)
return
# 스윙분은 전량 소진, 잔량은 전부 DT — quantity=0 유령 active 행을 남기지 않는다.
await _close_filtered(strategy_name_not=DT_STRATEGY_NAMES)
logger.warning(
f"[MDD] {stock_code}: 스윙분 전량 축소(잔량 {remaining_qty}주는 DT) — "
f"스윙 ActiveTrade 종료, DT 행은 DT 청산 경로에 위임"
)
return
await _upsert_non_dt(_swing_ratio, _swing_left)
logger.warning(
f"[MDD] {stock_code}: DT {dt_qty}주 병존 — 스윙 ActiveTrade 만 잔량 갱신 "
f"(스윙 잔량={_swing_left}/{_swing_entry_qty}, 배율={_swing_ratio:.4f}, "
f"DT 잔량={_dt_left}, DT 행은 DT 청산 경로에 위임)"
)
else:
await _upsert(None, sell_ratio, remaining_qty)
async def _reflect_reduction(
stock_code: str, sell_qty: int, price: float, remaining_qty: int
) -> None:
@@ -555,17 +851,48 @@ async def _reflect_reduction(
except Exception as e:
logger.warning(f"[MDD] Portfolio 반영 실패 ({stock_code}): {e}")
# [항목11 2026-09-27] 인메모리 차감 규칙을 _sync_active_trade_after_reduction 과 통일.
# ActiveTrade 측 규칙: _dt_left = max(0, dt_qty - _sold) (= DT 우선 차감)
# 단, dt_qty > quantity(KIS 잔고) 면 stale 로 보고 close 를 생략.
# 기존 코드는 DT 우선 차감(max(0, dt-sell))까지는 같았지만 stale 케이스를 가드하지
# 않아, 인메모리 잔량이 실제 잔고(remaining_qty)를 초과한 채 남았다.
# 예) 잔고 quantity=10, 인메모리 dt_qty=15, 매도 3주 → 잔고 7주인데 인메모리 12주
# → 다음 DT 청산이 12주 매도를 시도해 과다매도/주문거부.
# 따라서 (1) DT 우선 차감, (2) 실제 잔고 잔량으로 상한 클램프, (3) 음수 금지.
try:
from app.services.daytrading.position_manager import day_trading_state
async with day_trading_state.trade_lock:
pos = day_trading_state.positions.get(stock_code)
if pos:
_left = max(0, int(pos.get("quantity", 0) or 0) - sell_qty)
_dt_now = max(0, int(pos.get("quantity", 0) or 0))
_balance_before = max(0, sell_qty + remaining_qty) # = KIS 잔고(quantity)
_stale = _dt_now > _balance_before
if _stale:
# 인메모리가 잔고보다 큼 = 이미 외부 경로로 매도된 분이 반영 안 됨.
# 여기서 또 sell_qty 를 빼면 실제 잔고와 더 벌어지므로, 중복 차감 없이
# 실제 잔량으로 클램프만 한다(_sync 의 보수적 stale 처리와 동일 취지).
_left = min(_dt_now, remaining_qty)
logger.warning(
f"[MDD] {stock_code}: DT 인메모리 수량이 잔고를 초과 — stale 판단 "
f"(잔고 {_balance_before}주, 인메모리 {_dt_now}주, 매도 {sell_qty}주) → "
f"중복 차감 생략, 실제 잔량 {remaining_qty}주로 클램프"
)
else:
# DT 우선 차감 (ActiveTrade 의 _dt_left 계산과 동일 규칙)
_left = max(0, _dt_now - sell_qty)
# 잔고 잔량 초과 방지 (이론상 불가하나 이중 방어)
_left = min(_left, remaining_qty)
if _left <= 0:
day_trading_state.remove_position(stock_code)
logger.warning(f"[MDD] DT 포지션 전량 축소: {stock_code}")
logger.warning(
f"[MDD] DT 포지션 전량 축소: {stock_code} "
f"(인메모리 {_dt_now}주 → 0, 매도 {sell_qty}주, 잔고 잔량 {remaining_qty}주)"
)
else:
pos["quantity"] = _left
logger.warning(f"[MDD] DT 포지션 잔량 갱신: {stock_code} → {_left}주")
logger.warning(
f"[MDD] DT 포지션 잔량 갱신: {stock_code} {_dt_now} → {_left}주 "
f"(매도 {sell_qty}주, 잔고 잔량 {remaining_qty}주)"
)
except Exception as e:
logger.warning(f"[MDD] DT 인메모리 반영 실패 ({stock_code}): {e}")