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

@@ -216,6 +216,61 @@ class KoreaInvestmentAPI:
instance._load_token_from_cache()
return instance
@classmethod
def for_user(
cls,
user_id: int,
app_key: str,
app_secret: str,
account_no: str,
mode: str,
) -> "KoreaInvestmentAPI":
"""사용자별 주문·잔고 전용 인스턴스 생성 (멀티테넌트 1단계, §5.5).
시세 조회는 `_quote_delegate()`가 전역 `real_market_api`(공용, 계좌 무관)로
자동 위임하므로, 이 인스턴스의 앱키는 주문·잔고·체결확인에만 소비된다.
`place_order`/`get_balance`/`cancel_order` 등은 모두 `self.base_url`
(`"vts" in base_url`)로 모의/실전을 판별하므로, 전역 `settings.KIS_MODE`를
건드리지 않고 인스턴스별 `mode`가 그대로 적용된다(최소 침습).
자격증명 복호화는 호출 측(Registry/KISClientPool)이 수행해 평문으로 전달한다 —
이 클래스는 DB 세션을 열지 않는다.
"""
if mode not in ("virtual", "real"):
raise ValueError(f"지원하지 않는 mode: {mode!r} (virtual|real)")
instance = cls.__new__(cls)
instance.base_url = settings.KIS_REAL_URL if mode == "real" else settings.KIS_VIRTUAL_URL
instance.app_key = app_key
instance.app_secret = app_secret
instance.account_number = account_no
instance.mode = mode
instance.user_id = user_id
instance.access_token = None
instance.token_expires_at = None
instance.last_request_time = None
instance._throttle_lock = asyncio.Lock()
instance._http_client = None
instance._http_client_lock = asyncio.Lock()
token_dir = Path.home() / ".cache" / "auto-stock" / "users" / str(user_id)
token_dir.mkdir(parents=True, exist_ok=True)
instance.TOKEN_CACHE_FILE = token_dir / f"kis_token_{mode}.json"
instance.min_request_interval = (
0.5 if mode == "virtual" else getattr(settings, "KIS_REAL_MIN_REQUEST_INTERVAL", 0.1)
)
instance.max_retries = settings.API_MAX_RETRIES
instance.retry_delay = settings.API_RETRY_DELAY
instance.retry_backoff = settings.API_RETRY_BACKOFF
instance.timeout = settings.API_TIMEOUT
instance.semaphore = asyncio.Semaphore(settings.API_MAX_CONCURRENT_REQUESTS)
instance._token_lock = asyncio.Lock()
instance.monitor = APICallMonitor(window_size=settings.API_MONITORING_WINDOW)
instance.circuit_breaker = CircuitBreaker(failure_threshold=7, recovery_timeout=120)
logger.info(
"🔧 KIS 사용자별 API 초기화: user_id=%s mode=%s base_url=%s app_key=%s...",
user_id, mode, instance.base_url, app_key[:6] if app_key else ""
)
instance._load_token_from_cache()
return instance
def __init__(self):
self.base_url = settings.KOREA_INVESTMENT_API_BASE_URL
self.app_key = settings.KOREA_INVESTMENT_APP_KEY