fix(P1): MDD 축소 수량 반영·ActiveTrade 전략명 필수화·스윙 매도에서 DT 보유분 제외

- mdd_manager: 강제 축소가 TradeHistory/remaining_pct 만 갱신하고 실제 수량은 어디도
  줄이지 않아 다음 청산이 축소 전 수량으로 매도했음 → _reflect_reduction()으로
  Portfolio·DT 인메모리 반영, ActiveTrade.quantity 도 잔량으로 갱신 (두 경로 모두)
- swing/exit_service: close/upsert 에 strategy_name 미전달 시 db_worker 가 최신 active
  행(=DT)을 닫거나 트레일링 고점을 덮어씀 → 항상 전략명 명시
- trade_executor._execute_sell: Portfolio 행(스윙+DT 합계)을 그대로 매도 대상으로 삼아
  DT 보유분까지 팔았음 → 엔진 보유 수량을 제외한 자기 전략 보유분만 대상, upsert 에 전략명
- portfolio.sync: DT 태깅이 해외 루프에만 적용돼 국내 DT 매수분이 swing 으로 저장되면
  스윙 엔진이 손절 → 국내에도 적용. _dt_qty 정의를 output1 블록 밖으로 올려 NameError 방지

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 10:53:13 +09:00
parent 70def8fe0b
commit 9cdd12b41e
5 changed files with 138 additions and 13 deletions

View File

@@ -521,6 +521,18 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
# 디버깅: KIS API 전체 응답 로깅
logger.info(f"🔍 KIS API balance response keys: {balance_data.keys() if balance_data else 'None'}")
# 데이트레이딩 엔진이 보유 중인 종목 → strategy_type='daytrading' 태깅용.
# (국내 output1 블록 안에서 정의하면 해외 루프에서 NameError → sync 전체 실패)
try:
from app.services.daytrading.position_manager import day_trading_state as _dts
_dt_qty = {
code: int(pos.get("quantity", 0) or 0)
for code, pos in _dts.positions.items()
}
except Exception:
_dt_qty = {}
_dt_codes = set(_dt_qty)
# Balance API 오류 시 기존 DB 보유종목을 삭제하지 않고 캐시 유지
if not balance_data or balance_data.get('rt_cd') != '0':
error_msg = balance_data.get('msg1', 'Unknown error') if balance_data else 'No response'
@@ -609,17 +621,6 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
_realtime_price_map = {}
# ──────────────────────────────────────────────────────────────────
# 데이트레이딩 엔진이 보유 중인 종목 → strategy_type='daytrading' 태깅용
try:
from app.services.daytrading.position_manager import day_trading_state as _dts
_dt_qty = {
code: int(pos.get("quantity", 0) or 0)
for code, pos in _dts.positions.items()
}
_dt_codes = set(_dt_qty)
except Exception:
_dt_qty, _dt_codes = {}, set()
for item in balance_data["output1"]:
stock_code = item.get("pdno")
@@ -682,6 +683,9 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
h.profit_loss = profit_loss
h.profit_loss_rate = profit_loss_rate
h.market = "KRX"
# 전량이 DT 포지션일 때만 태깅(혼재 시 스윙 청산 대상 유지)
if stock_code in _dt_codes and quantity <= _dt_qty.get(stock_code, 0):
h.strategy_type = "daytrading"
new_holdings.append(h)
else:
new_holding = Portfolio(
@@ -693,6 +697,11 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
profit_loss=profit_loss,
profit_loss_rate=profit_loss_rate,
market="KRX",
strategy_type=(
"daytrading"
if quantity <= _dt_qty.get(stock_code, 0)
else "swing"
),
)
db.add(new_holding)
new_holdings.append(new_holding)

View File

@@ -329,8 +329,13 @@ class MDDManager:
await enqueue_active_trade_upsert({
"stock_code": stock_code,
"remaining_pct_multiply": sell_ratio,
"quantity": remaining_qty, # 잔량 갱신(미갱신 시 다음 청산이 과다매도)
})
# [2026-09-23] 축소 체결분을 Portfolio·DT 인메모리에도 반영.
# 누락 시 다음 청산이 축소 전 수량으로 매도한다(과다매도).
await _reflect_reduction(stock_code, sell_qty, current_price, remaining_qty)
results.append({
"stock_code": stock_code,
"stock_name": stock_name,
@@ -490,8 +495,13 @@ class MDDManager:
"user_id": user_id,
"stock_code": stock_code,
"remaining_pct_multiply": sell_ratio,
"quantity": remaining_qty,
})
# [2026-09-23] 축소 체결분을 Portfolio·DT 인메모리에도 반영.
# 누락 시 다음 청산이 축소 전 수량으로 매도한다(과다매도).
await _reflect_reduction(stock_code, sell_qty, current_price, remaining_qty)
results.append({
"stock_code": stock_code,
"stock_name": stock_name,
@@ -530,3 +540,32 @@ class MDDManager:
# 싱글톤 인스턴스
mdd_manager = MDDManager()
async def _reflect_reduction(
stock_code: str, sell_qty: int, price: float, remaining_qty: int
) -> None:
"""MDD 축소 체결을 Portfolio 캐시/DB 와 데이트레이딩 인메모리 포지션에 반영.
ActiveTrade 잔량은 호출측에서 upsert 하므로 여기서는 Portfolio 와 인메모리만 다룬다.
"""
try:
from app.services.portfolio_cache_bridge import reflect_sell
await reflect_sell(stock_code, sell_qty, price, remaining_qty=remaining_qty)
except Exception as e:
logger.warning(f"[MDD] Portfolio 반영 실패 ({stock_code}): {e}")
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)
if _left <= 0:
day_trading_state.remove_position(stock_code)
logger.warning(f"[MDD] DT 포지션 전량 축소: {stock_code}")
else:
pos["quantity"] = _left
logger.warning(f"[MDD] DT 포지션 잔량 갱신: {stock_code} → {_left}주")
except Exception as e:
logger.warning(f"[MDD] DT 인메모리 반영 실패 ({stock_code}): {e}")

View File

@@ -200,6 +200,9 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
# ── ActiveTrade 조회 (분할 청산 상태) ────────────────────
active_trade = await get_swing_active_trade(db, stock_code)
# ActiveTrade upsert/close 시 항상 전략명을 명시해야 같은 종목의 DT 행을
# 잘못 건드리지 않는다(db_worker 는 전략명이 없으면 최신 행을 매칭).
_at_strategy = (active_trade.strategy_name if active_trade else None) or "swing"
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
@@ -209,12 +212,13 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
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,
})
# ── 전략별 청산 분기 ────────────────────────────────────
exit_signal = None
_strategy = active_trade.strategy_name if active_trade else "swing"
_strategy = _at_strategy
if domestic:
try:
@@ -341,13 +345,18 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
# 전량 청산이면 ActiveTrade 종료, 분할 매도면 remaining_pct 갱신
if sell_qty >= quantity:
await enqueue_close_active_trade(stock_code, strategy_name=None)
# strategy_name 을 비우면 db_worker 가 '가장 최근 active 행'을 닫는다
# → 같은 종목에 DT 행이 있으면 그쪽이 닫히고 스윙 행은 영구 잔존.
await enqueue_close_active_trade(
stock_code, strategy_name=_strategy or "swing"
)
else:
# 분할 매도 후 remaining_pct 갱신 (반복 트리거 방지)
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",
"remaining_pct": new_remaining,
# 잔량도 함께 갱신 — 누락 시 재기동 복원이 원래 수량으로 되살아나
# 보유량보다 많이 매도한다(2026-09-23 DT LG씨엔에스 사례와 동일 패턴)

View File

@@ -1134,7 +1134,30 @@ async def _execute_sell(
"""
# [CLAUDE.md 5.2] 분할매도: sell_pct에 따라 매도 수량 결정
# [2026-09-23] Portfolio 행은 stock_code UNIQUE 라 같은 종목의 스윙+데이트레이딩
# 수량이 합산돼 있다. 행 전체를 대상으로 잡으면 다른 전략 보유분까지 매도된다
# → 데이트레이딩 엔진이 들고 있는 수량은 제외한 '이 전략의 보유분'만 대상으로 한다.
total_qty = holding.quantity
_strategy_tag = stock.get("strategy_type", "swing")
if _strategy_tag != "daytrading":
try:
from app.services.daytrading.position_manager import day_trading_state as _dts
_dt_pos = _dts.positions.get(stock_code)
_dt_qty = int((_dt_pos or {}).get("quantity", 0) or 0)
except Exception:
_dt_qty = 0
if _dt_qty > 0:
_own = max(0, total_qty - _dt_qty)
if _own <= 0:
logger.info(
f"[SELL] {stock_name}: 보유 {total_qty}주 전량이 데이트레이딩 포지션 — 스윙 청산 스킵"
)
return {"action": "SKIP", "reason": "owned_by_daytrading", "stock": stock_name}
logger.info(
f"[SELL] {stock_name}: 보유 {total_qty}주 중 DT {_dt_qty}주 제외 → {_own}주 대상"
)
total_qty = _own
quantity = max(1, int(total_qty * sell_pct)) # 최소 1주
if sell_pct >= 1.0:
quantity = total_qty # 전량매도 시 잔여분 없도록
@@ -1255,6 +1278,7 @@ async def _execute_sell(
new_remaining = max(0.0, remaining_pct - sell_pct)
await enqueue_active_trade_upsert({
"stock_code": stock_code,
"strategy_name": stock.get("strategy_type", "swing"),
"remaining_pct": new_remaining,
"quantity": max(0, total_qty - quantity), # 잔량 갱신(과다매도 방지)
"highest_since_entry": current_price,