feat: 멀티테넌트 1단계 — 사용자별 KIS 계좌·포트폴리오·수동매매 (MULTI_TENANT_ENABLED 기본 OFF)
설계: docs/multi_tenant_design.md / 검수: docs/multi_tenant_phase1_review.md (High 0, MT=False 배포 가능)
데이터·인프라
- user_kis_credentials(Fernet 암호화, PBKDF2 키 파생, key_version), stock_bans_v2 신설,
portfolio/trade_history/active_trades/trade_journals/trading_sessions 등 user_id nullable 추가
- alembic 0009~0011(idempotent, legacy owner 백필). main.py 기동 시 portfolio.user_id 스키마 검사(H1)
- Jenkinsfile: DB Migration 을 Deploy 앞으로, alembic 이력 없으면 stamp 0008 후 upgrade(N1)
- bootstrap: users 비어 있으면 BOOTSTRAP_ADMIN_* 로 관리자 시드 + .env KIS 자격증명 이관 + 백필(SAVEPOINT)
백엔드
- core/crypto.py, core/tenant_auth.py(get_current_user — 비활성 사용자 401, API 키→legacy owner,
deny_non_owner_session_when_mt), core/dt_utils.py
- services/tenant: context, kis_pool, registry(UserContextRegistry, 락 최소화·복호화 to_thread), risk_factory
- KoreaInvestmentAPI.for_user(사용자별 인스턴스, 토큰캐시 users/{id}/, 시세는 공용 위임)
- trade_executor.safe_place_order_v2(사용자별 락/세마포어, legacy ctx 는 기존 키)
- mdd_manager/loss_tracker _v2 + ban_until tz-aware/naive 경계 정규화(레거시 경로 버그 포함 수정)
- db_worker: payload user_id 옵션 필터, upsert_stock_ban_v2, legacy ActiveTrade 조회 LIMIT 1
- API: /account/credentials(등록·마스킹조회·삭제·연결테스트·자동매매), /auth/admin/tenant/*,
/portfolio/v2(조회·요약·sync·buy·sell), /trades/v2(+pending). PortfolioResponse Optional 수정
프론트
- /account 내 계좌 설정, useTenantMode(세션=v2 / API키=legacy, 미확정 시 쿼리·버튼 비활성),
계좌 미등록 인라인 안내, v2 매수/매도/거래이력 분기
테스트: tests/integration(격리 Postgres, KIS 전면 mock) 20 passed / 1 xfailed(0012 이전 제약)
2단계(스케줄러 사용자화·legacy 라우터 격리) 전까지 MULTI_TENANT_ENABLED=True 전환 금지
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,8 @@ import json
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
|
||||
from app.core.json_helpers import _dumps
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MDD_REDIS_KEY = "mdd:state"
|
||||
@@ -41,7 +43,7 @@ class MDDManager:
|
||||
"_reduction_executed_10pct": self._reduction_executed_10pct,
|
||||
"_reduction_executed_20pct": self._reduction_executed_20pct,
|
||||
}
|
||||
await r.set(_MDD_REDIS_KEY, json.dumps(state))
|
||||
await r.set(_MDD_REDIS_KEY, _dumps(state))
|
||||
except Exception as e:
|
||||
logger.warning(f"[MDD] Redis 저장 실패: {e}")
|
||||
|
||||
@@ -138,6 +140,52 @@ class MDDManager:
|
||||
await self.save_to_redis()
|
||||
return {"action": "NONE", "drawdown": drawdown_pct}
|
||||
|
||||
async def save_to_redis_v2(self, user_id: int) -> None:
|
||||
"""[v2] 사용자 스코프 MDD 상태를 `mdd:state:{user_id}` 키로 영속화.
|
||||
|
||||
save_to_redis()와 동일 직렬화 포맷 재사용, 키 네임스페이스만 사용자별로 분리.
|
||||
레거시 save_to_redis()는 불변(전역 "mdd:state" 키 그대로).
|
||||
"""
|
||||
try:
|
||||
from app.core.redis import get_redis
|
||||
r = get_redis()
|
||||
if r:
|
||||
state = {
|
||||
"peak_balance": self.peak_balance,
|
||||
"is_paused": self.is_paused,
|
||||
"is_short_term_strategy_paused": self.is_short_term_strategy_paused,
|
||||
"reduction_applied": self.reduction_applied,
|
||||
"_reduction_executed_10pct": self._reduction_executed_10pct,
|
||||
"_reduction_executed_20pct": self._reduction_executed_20pct,
|
||||
}
|
||||
await r.set(f"{_MDD_REDIS_KEY}:{user_id}", _dumps(state))
|
||||
except Exception as e:
|
||||
logger.warning(f"[MDD v2] Redis 저장 실패 (user={user_id}): {e}")
|
||||
|
||||
async def load_from_redis_v2(self, user_id: int) -> bool:
|
||||
"""[v2] `mdd:state:{user_id}` 키에서 사용자 스코프 MDD 상태 복원."""
|
||||
try:
|
||||
from app.core.redis import get_redis
|
||||
r = get_redis()
|
||||
if r:
|
||||
raw = await r.get(f"{_MDD_REDIS_KEY}:{user_id}")
|
||||
if raw:
|
||||
state = json.loads(raw)
|
||||
self.peak_balance = float(state.get("peak_balance", 0.0))
|
||||
self.is_paused = bool(state.get("is_paused", False))
|
||||
self.is_short_term_strategy_paused = bool(state.get("is_short_term_strategy_paused", False))
|
||||
self.reduction_applied = state.get("reduction_applied")
|
||||
self._reduction_executed_10pct = bool(state.get("_reduction_executed_10pct", False))
|
||||
self._reduction_executed_20pct = bool(state.get("_reduction_executed_20pct", False))
|
||||
logger.info(
|
||||
f"[MDD v2] Redis에서 복원: user={user_id} peak={self.peak_balance:,.0f}, "
|
||||
f"paused={self.is_paused}, reduction={self.reduction_applied}"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"[MDD v2] Redis 복원 실패 (user={user_id}): {e}")
|
||||
return False
|
||||
|
||||
async def can_open_new_position(self) -> bool:
|
||||
"""신규 포지션 진입 가능 여부 (thread-safe)"""
|
||||
async with self._lock:
|
||||
@@ -320,6 +368,165 @@ class MDDManager:
|
||||
setattr(self, executed_key, False)
|
||||
return []
|
||||
|
||||
async def execute_reduction_v2(self, mdd_action: dict, kis, user_id: int) -> list[dict]:
|
||||
"""[v2] 사용자 스코프 포지션 축소 매도 실행.
|
||||
|
||||
execute_reduction()과 동일 로직이나 전역 korea_investment_api 대신 주입된
|
||||
kis(사용자 인스턴스)를 사용하고, trade_history/active_trade 큐 payload에
|
||||
user_id를 포함해 db_worker가 해당 사용자 레코드만 갱신하도록 한다.
|
||||
기존 execute_reduction()은 완전히 불변.
|
||||
"""
|
||||
action = mdd_action.get("action", "NONE")
|
||||
if action == "NONE":
|
||||
return []
|
||||
|
||||
reduce_pct = mdd_action.get("reduce_pct", 0.0)
|
||||
prioritize_leverage = mdd_action.get("prioritize_leverage", False)
|
||||
|
||||
if reduce_pct <= 0:
|
||||
return []
|
||||
|
||||
executed_key: Optional[str] = None
|
||||
|
||||
async with self._lock:
|
||||
if self.reduction_applied is None:
|
||||
logger.debug(f"[MDD v2] user={user_id} reduction_applied=None — 축소 불필요, 스킵")
|
||||
return []
|
||||
executed_key = f"_reduction_executed_{self.reduction_applied}"
|
||||
if getattr(self, executed_key, False):
|
||||
logger.debug(f"[MDD v2] user={user_id} {action} 축소 이미 실행됨 — 스킵")
|
||||
return []
|
||||
setattr(self, executed_key, True)
|
||||
|
||||
try:
|
||||
from app.services.db_worker import (
|
||||
enqueue_trade_history,
|
||||
enqueue_close_active_trade_v2,
|
||||
enqueue_active_trade_upsert,
|
||||
)
|
||||
|
||||
balance = await kis.get_balance()
|
||||
holdings = balance.get("output1", [])
|
||||
if not holdings:
|
||||
logger.info(f"[MDD v2] user={user_id} {action} — 보유 종목 없음, 축소 불필요")
|
||||
return []
|
||||
|
||||
LEVERAGE_KEYWORDS = {"레버리지", "2X", "3X", "인버스", "TIGER 200선물", "KODEX 인버스", "곱버스"}
|
||||
if prioritize_leverage:
|
||||
def _is_leverage(h):
|
||||
name = h.get("prdt_name", "")
|
||||
return any(kw in name for kw in LEVERAGE_KEYWORDS)
|
||||
holdings = sorted(holdings, key=lambda h: (not _is_leverage(h),))
|
||||
|
||||
results = []
|
||||
skipped = 0
|
||||
for h in holdings:
|
||||
stock_code = h.get("pdno", "")
|
||||
stock_name = h.get("prdt_name", stock_code)
|
||||
quantity = int(h.get("hldg_qty", 0) or 0)
|
||||
current_price = float(h.get("prpr", 0) or 0)
|
||||
|
||||
if quantity <= 0 or current_price <= 0:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
sell_qty = max(int(quantity * reduce_pct), 1)
|
||||
sell_qty = min(sell_qty, quantity)
|
||||
|
||||
try:
|
||||
# UserContext 조립 완료(트레이딩팀) — 종목별 동시 주문 락
|
||||
# (_order_semaphores/Redis 분산락)을 거치도록 safe_place_order_v2 사용.
|
||||
# SL/사이징 로직은 그대로, 주문 호출 경로만 교체.
|
||||
from app.services.tenant.context import UserContext
|
||||
from app.services.trade_executor import safe_place_order_v2
|
||||
|
||||
from app.core.config import settings as _settings
|
||||
|
||||
reduction_ctx = UserContext(
|
||||
user_id=user_id,
|
||||
username="",
|
||||
mode=getattr(kis, "mode", _settings.KIS_MODE),
|
||||
kis=kis,
|
||||
mdd=self,
|
||||
loss_tracker=None,
|
||||
)
|
||||
order_resp = await safe_place_order_v2(
|
||||
reduction_ctx,
|
||||
stock_code=stock_code,
|
||||
order_type="SELL",
|
||||
quantity=sell_qty,
|
||||
price=None, # 시장가
|
||||
)
|
||||
if order_resp and order_resp.get("rt_cd") == "0":
|
||||
order_id = order_resp.get("output", {}).get("ODNO", "")
|
||||
avg_price = float(h.get("pchs_avg_pric", current_price))
|
||||
profit_loss = (current_price - avg_price) * sell_qty
|
||||
profit_loss_rate = ((current_price - avg_price) / avg_price * 100) if avg_price > 0 else 0.0
|
||||
|
||||
# MDD 강제매도는 시스템적 리스크 축소 — loss_tracker 미기록 (execute_reduction과 동일 정책)
|
||||
await enqueue_trade_history({
|
||||
"user_id": user_id,
|
||||
"stock_code": stock_code,
|
||||
"stock_name": stock_name,
|
||||
"order_type": "SELL",
|
||||
"order_price": current_price,
|
||||
"order_quantity": sell_qty,
|
||||
"executed_price": current_price,
|
||||
"executed_quantity": sell_qty,
|
||||
"total_amount": current_price * sell_qty,
|
||||
"status": "COMPLETED",
|
||||
"order_id": order_id,
|
||||
"exit_reason": f"MDD {action} ({reduce_pct*100:.0f}% 축소)",
|
||||
"net_profit_loss": profit_loss,
|
||||
"strategy_type": "mdd_reduction",
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
results.append({
|
||||
"stock_code": stock_code,
|
||||
"stock_name": stock_name,
|
||||
"sell_quantity": sell_qty,
|
||||
"reason": action,
|
||||
})
|
||||
logger.warning(
|
||||
f"[MDD v2] user={user_id} {action} 매도: {stock_code} {stock_name} "
|
||||
f"{sell_qty}주 (총 {quantity}주 중 {reduce_pct*100:.0f}%)"
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.3) # KIS API 레이트 리밋
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[MDD v2] user={user_id} 축소 매도 실패 ({stock_code}): {e}")
|
||||
|
||||
failed_count = len(holdings) - len(results) - skipped
|
||||
if failed_count > 0 and executed_key is not None:
|
||||
async with self._lock:
|
||||
setattr(self, executed_key, False)
|
||||
logger.warning(
|
||||
f"[MDD v2] user={user_id} {action} 부분 완료: {len(results)}종목 성공, "
|
||||
f"{failed_count}종목 실패 → 다음 주기 재시도 예정"
|
||||
)
|
||||
else:
|
||||
logger.warning(f"[MDD v2] user={user_id} {action} 완료: {len(results)}종목 축소 매도")
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[MDD v2] user={user_id} execute_reduction_v2 실패: {e}", exc_info=True)
|
||||
if executed_key is not None:
|
||||
async with self._lock:
|
||||
setattr(self, executed_key, False)
|
||||
return []
|
||||
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
mdd_manager = MDDManager()
|
||||
|
||||
Reference in New Issue
Block a user