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:
2026-09-21 10:26:51 +09:00
parent dc128ae4cf
commit b4a1fc79ab
52 changed files with 5614 additions and 96 deletions

View File

@@ -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()