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

@@ -0,0 +1,5 @@
"""멀티테넌트(사용자별 매매) 서비스 계층 — 1단계.
- bootstrap: 앱 최초 기동 시 관리자 계정·KIS 자격증명 이관 시드
- (2단계 예정) context.py / registry.py / kis_pool.py
"""

View File

@@ -0,0 +1,174 @@
"""앱 기동 시 멀티테넌트 부트스트랩 (관리자 시드 + legacy 자격증명 이관 + 백필).
`main.py` lifespan 에서 `create_all()` 직후 1회 호출된다(§ CLAUDE.md 예외: 부트스트랩
시드·자격증명 저장은 단일 트랜잭션 직접 커밋 허용 — 실시간 계층 아님).
모든 단계는 독립적으로 try/except 로 감싸 실패해도 앱 부팅을 막지 않는다.
alembic 0011 과 동일한 백필 로직을 idempotent 하게 재수행 — alembic 이 CLI 로만
실행되는 배포 환경에서도(예: 최초 로그인 전) 앱 기동 시 스스로 보정한다.
"""
import logging
import bcrypt
from sqlalchemy import select, text
from app.core.config import settings
from app.core.crypto import current_key_version, encrypt, is_encryption_configured
from app.core.database import AsyncSessionLocal
from app.models.models import User, UserKisCredential
logger = logging.getLogger(__name__)
# alembic 0011 과 동일한 백필 대상 테이블 목록
_BACKFILL_TABLES = [
"portfolio",
"trade_history",
"active_trades",
"trade_journals",
"trading_sessions",
"mcp_tool_calls",
"claude_analysis_log",
]
def _hash_password(password: str) -> str:
try:
from app.api.endpoints.auth import _hash_password as _auth_hash_password
return _auth_hash_password(password)
except ImportError:
# 순환 임포트 등으로 재사용 불가할 때만 동일 로직 복제 (auth.py:_hash_password 와 동치)
return bcrypt.hashpw(password.encode()[:72], bcrypt.gensalt()).decode()
async def _seed_admin_if_empty(session) -> None:
"""users 테이블이 비어 있고 BOOTSTRAP_ADMIN_PASSWORD 가 설정되어 있으면 관리자 시드."""
if not settings.BOOTSTRAP_ADMIN_PASSWORD:
return
total = (await session.execute(select(User))).scalars().first()
if total is not None:
return # 이미 사용자 존재 — 시드 불필요
user = User(
username=settings.BOOTSTRAP_ADMIN_USERNAME,
password_hash=_hash_password(settings.BOOTSTRAP_ADMIN_PASSWORD),
is_active=True,
is_admin=True,
)
session.add(user)
await session.commit()
logger.info(f"[Bootstrap] 부트스트랩 관리자 계정 생성 완료: {settings.BOOTSTRAP_ADMIN_USERNAME}")
async def _get_legacy_owner(session) -> User | None:
result = await session.execute(
select(User).where(User.is_admin.is_(True)).order_by(User.id.asc()).limit(1)
)
return result.scalar_one_or_none()
async def _seed_legacy_kis_credential(session, owner: User) -> None:
"""legacy owner 에게 .env KIS 자격증명이 아직 이관되지 않았으면 암호화해 저장."""
existing = await session.execute(
select(UserKisCredential).where(UserKisCredential.user_id == owner.id)
)
if existing.scalar_one_or_none() is not None:
return # 이미 이관됨
app_key = settings.KOREA_INVESTMENT_APP_KEY
app_secret = settings.KOREA_INVESTMENT_APP_SECRET
account_no = (settings.KOREA_INVESTMENT_ACCOUNT_NUMBER or "").replace("-", "")
if not (app_key and app_secret and account_no):
logger.info("[Bootstrap] .env KIS 자격증명 미설정 — legacy 자격증명 이관 스킵")
return
if not is_encryption_configured():
logger.warning("[Bootstrap] CREDENTIALS_ENCRYPTION_KEY/SECRET_KEY 미설정 — legacy 자격증명 이관 스킵")
return
import hashlib
app_key_hash = hashlib.sha256(app_key.encode()).hexdigest()
dup = await session.execute(
select(UserKisCredential).where(UserKisCredential.app_key_hash == app_key_hash)
)
if dup.scalar_one_or_none() is not None:
logger.warning("[Bootstrap] 동일 앱키가 이미 등록되어 있어 legacy 이관 스킵")
return
account_product_code = account_no[8:10] if len(account_no) >= 10 else "01"
credential = UserKisCredential(
user_id=owner.id,
mode=settings.KIS_MODE,
app_key_enc=encrypt(app_key),
app_secret_enc=encrypt(app_secret),
account_no_enc=encrypt(account_no),
account_product_code=account_product_code,
app_key_hash=app_key_hash,
app_key_hint=app_key[:6],
is_enabled=True,
auto_trade_enabled=settings.AUTO_TRADE_ENABLED,
last_verified_at=None,
key_version=current_key_version(),
)
session.add(credential)
await session.commit()
logger.info(f"[Bootstrap] legacy KIS 자격증명 이관 완료 (owner={owner.username}, app_key={app_key[:6]}…)")
async def _backfill_legacy_owner_ids(session, owner_id: int) -> None:
"""alembic 0011 과 동일한 백필을 idempotent 하게 재수행 (user_id IS NULL 인 행만 대상).
M8: PostgreSQL은 트랜잭션 내 한 문장이 실패(예: 테이블/컬럼 미존재)하면 세션이
`InFailedSqlTransaction` 상태가 되어 이후 모든 문장이 연쇄 실패한다. 문장별로
`session.begin_nested()`(SAVEPOINT)로 감싸 한 테이블의 실패가 나머지 테이블
백필을 막지 않게 한다.
"""
for table in _BACKFILL_TABLES:
try:
async with session.begin_nested():
await session.execute(
text(f"UPDATE {table} SET user_id = :owner WHERE user_id IS NULL"),
{"owner": owner_id},
)
except Exception as e: # 컬럼/테이블이 아직 없는 초기 상태 등 — 개별 실패는 무시하고 계속
logger.debug(f"[Bootstrap] {table} 백필 스킵: {e}")
try:
async with session.begin_nested():
await session.execute(
text(
"""
INSERT INTO stock_bans_v2 (user_id, stock_code, consecutive_losses, ban_until, updated_at)
SELECT :owner, stock_code, consecutive_losses, ban_until, updated_at
FROM stock_bans
ON CONFLICT (user_id, stock_code) DO NOTHING
"""
),
{"owner": owner_id},
)
except Exception as e:
logger.debug(f"[Bootstrap] stock_bans_v2 백필 스킵: {e}")
await session.commit()
async def ensure_bootstrap_admin() -> None:
"""앱 기동 시 1회 호출. 실패해도 예외를 전파하지 않는다(로그만 남김)."""
try:
async with AsyncSessionLocal() as session:
await _seed_admin_if_empty(session)
except Exception as e:
logger.error(f"[Bootstrap] 관리자 계정 시드 실패 (non-critical): {e}")
try:
async with AsyncSessionLocal() as session:
owner = await _get_legacy_owner(session)
if owner is None:
logger.info("[Bootstrap] 관리자 계정 없음 — legacy 자격증명 이관/백필 스킵")
return
await _seed_legacy_kis_credential(session, owner)
await _backfill_legacy_owner_ids(session, owner.id)
except Exception as e:
logger.error(f"[Bootstrap] legacy 자격증명 이관/백필 실패 (non-critical): {e}")

View File

@@ -0,0 +1,62 @@
"""멀티테넌트 사용자 실행 컨텍스트 (설계 문서 §2, §5.6).
`UserContext`는 사용자 1명의 매매 실행에 필요한 모든 상태(KIS 클라이언트,
리스크 매니저, 데이트레이딩 상태 등)를 한데 묶는다. 실제 조립(레지스트리)은
dev-team/risk-monitor-agent가 담당하는 `registry.py`에서 이루어지며, 이 파일은
데이터 구조만 정의한다 — import 순환을 피하기 위해 mdd/loss_tracker/dt_state는
구체 타입을 import하지 않고 `Any`로 둔다.
이 모듈은 DB 세션을 열지 않는다(자격증명 복호화는 호출 측이 수행해 넘겨준다).
"""
from dataclasses import dataclass
from typing import Any, Optional
from app.services.korea_investment_api import KoreaInvestmentAPI
@dataclass
class UserContext:
"""사용자 1명의 매매 실행 컨텍스트.
Attributes:
user_id: `users.id`. legacy(단일 계좌) 컨텍스트는 legacy owner id 또는 None.
username: 표시/로그용.
mode: "virtual" | "real" — `kis.base_url`과 동일해야 함.
kis: 이 사용자의 주문·잔고 전용 `KoreaInvestmentAPI` 인스턴스
(`KoreaInvestmentAPI.for_user(...)` 또는 legacy 전역 `korea_investment_api`).
mdd: 사용자별 `MDDManager` 인스턴스 (risk-monitor-agent 담당, 타입은 순환 방지 위해 Any).
loss_tracker: 사용자별 `ConsecutiveLossTracker` 인스턴스 (Any, 위와 동일 이유).
dt_state: 사용자별 `DayTradingState` 인스턴스 (2단계에서 사용, 1단계는 None 허용).
portfolio_cache: 사용자별 `PortfolioCache` 인스턴스 (2단계, 1단계는 None 허용).
auto_trade_enabled: 사용자 단위 자동매매 on/off.
is_legacy: True면 기존 전역 싱글톤(korea_investment_api/mdd_manager/loss_tracker)을
그대로 감싼 컨텍스트 — Redis 락/키 네임스페이스가 하위 호환 경로(user_id 미포함)를 쓴다.
"""
user_id: Optional[int]
username: str
mode: str
kis: KoreaInvestmentAPI
mdd: Any
loss_tracker: Any
dt_state: Any = None
portfolio_cache: Any = None
auto_trade_enabled: bool = False
is_legacy: bool = False
@property
def effective_auto_trade_enabled(self) -> bool:
"""`auto_trade_enabled`의 실시간 버전.
legacy 컨텍스트는 `registry.legacy()`가 조립 시점에 `settings.AUTO_TRADE_ENABLED`를
스냅샷해 최대 60초(TTL) 동안 고정한다(L4) — 런타임에 `.env`/설정을 토글해도
캐시가 갱신되기 전까지 반영되지 않는 문제. legacy 컨텍스트에서는 필드 대신 이
프로퍼티로 `settings.AUTO_TRADE_ENABLED`를 매번 다시 읽어 즉시 반영한다.
사용자 컨텍스트(is_legacy=False)는 DB에 저장된 사용자별 토글이 필드값이므로
그대로 반환한다. portfolio_v2 buy 가드는 `ctx.auto_trade_enabled` 대신 이
프로퍼티 사용을 권장.
"""
if self.is_legacy:
from app.core.config import settings
return settings.AUTO_TRADE_ENABLED
return self.auto_trade_enabled

View File

@@ -0,0 +1,75 @@
"""사용자별 `KoreaInvestmentAPI` 인스턴스 풀 (설계 문서 §5.5).
- 자격증명 복호화는 호출 측(Registry/credentials API, B그룹)이 수행해
평문(app_key/app_secret/account_no)을 넘겨준다 — 이 모듈은 DB 세션을 열지 않는다.
- 캐시 키는 `user_id` 하나뿐이다(1단계는 사용자당 자격증명 1세트 전제, §3.1).
자격증명이 바뀌면 호출 측이 반드시 `invalidate(user_id)`를 먼저 호출해야 한다
(여기서는 캐시가 있으면 그대로 재사용 — 자격증명 변경 감지를 하지 않음).
"""
import asyncio
import logging
from typing import Dict
from app.services.korea_investment_api import KoreaInvestmentAPI
from app.services import korea_investment_api as _kis_module
logger = logging.getLogger(__name__)
class KISClientPool:
"""사용자별 `KoreaInvestmentAPI` 인스턴스 캐시 (asyncio.Lock으로 동시 생성 방지)."""
def __init__(self) -> None:
self._instances: Dict[int, KoreaInvestmentAPI] = {}
self._lock = asyncio.Lock()
async def get_or_create(
self,
user_id: int,
app_key: str,
app_secret: str,
account_no: str,
mode: str,
) -> KoreaInvestmentAPI:
"""캐시에 있으면 반환, 없으면 `KoreaInvestmentAPI.for_user(...)`로 생성 후 캐시."""
existing = self._instances.get(user_id)
if existing is not None:
return existing
async with self._lock:
existing = self._instances.get(user_id)
if existing is not None:
return existing
instance = KoreaInvestmentAPI.for_user(
user_id=user_id,
app_key=app_key,
app_secret=app_secret,
account_no=account_no,
mode=mode,
)
self._instances[user_id] = instance
return instance
async def invalidate(self, user_id: int) -> None:
"""캐시된 인스턴스를 정리(httpx 클라이언트 close)하고 제거한다.
자격증명 교체/삭제 시 credentials API(B그룹)가 호출해야 한다.
"""
async with self._lock:
instance = self._instances.pop(user_id, None)
if instance is not None:
try:
await instance.close()
except Exception as e:
logger.warning("⚠️ KISClientPool.invalidate: user_id=%s close 실패: %s", user_id, e)
def get_legacy(self) -> KoreaInvestmentAPI:
"""전역 legacy 싱글톤 반환 (`MULTI_TENANT_ENABLED=False` 또는 legacy owner 경로용)."""
return _kis_module.korea_investment_api
def active_user_ids(self) -> list:
"""현재 캐시된(생성된) 사용자 id 목록."""
return list(self._instances.keys())
# 모듈 싱글톤
kis_client_pool = KISClientPool()

View File

@@ -0,0 +1,251 @@
"""사용자 실행 컨텍스트 레지스트리 (설계 문서 §2, §5.6).
`UserContext` 조립(자격증명 복호화 → KIS 클라이언트 풀 → 리스크 매니저 팩토리 →
상태 복원)을 한곳에 모아 캐싱한다. FastAPI 의존성 `get_user_context()`도 여기서
제공한다(트레이딩 실행/엔드포인트 양쪽에서 재사용).
- DB 세션은 항상 호출 측이 넘겨준다 — 이 모듈은 `AsyncSessionLocal()`을 직접
열지 않는다(§CLAUDE.md 아키텍처 원칙 1). `iter_active()`만 예외적으로 스케줄러가
세션을 만들어 넘겨주는 것을 전제로 `db` 인자를 필수로 받는다.
- 복호화 실패 등 개별 사용자 오류는 그 사용자만 None/스킵 처리하고 다른 사용자에
전파하지 않는다.
"""
import logging
import time
import asyncio
from fastapi import Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.crypto import CredentialDecryptError, decrypt
from app.core.database import get_db
from app.core.tenant_auth import CurrentUser, get_current_user, get_legacy_owner
from app.models.models import UserKisCredential
from app.services.tenant.context import UserContext
from app.services.tenant.kis_pool import kis_client_pool
from app.services.tenant.risk_factory import (
create_loss_tracker_for_user,
create_mdd_manager_for_user,
load_user_risk_state,
)
logger = logging.getLogger(__name__)
_LEGACY_OWNER_TTL_SEC = 60
_ACTIVE_LIST_TTL_SEC = 60
class UserContextRegistry:
"""사용자별 `UserContext` 캐시 + 조립 로직."""
def __init__(self) -> None:
self._contexts: dict[int, UserContext] = {}
self._lock = asyncio.Lock()
self._legacy_ctx: UserContext | None = None
self._legacy_owner_id: int | None = None
self._legacy_checked_at: float = 0.0
self._legacy_lock = asyncio.Lock()
self._legacy_owner_confirmed: bool = False
self._active_cache: list[UserContext] | None = None
self._active_cached_at: float = 0.0
# ── 사용자별 컨텍스트 조립 ────────────────────────────────────────────
async def get(self, user_id: int, db: AsyncSession) -> UserContext | None:
"""캐시 히트면 즉시 반환, 미스면 DB에서 자격증명을 읽어 새로 조립한다.
M4: `asyncio.Lock` 은 캐시 dict 갱신 구간(더블체크 패턴)만 감싼다 — DB I/O,
PBKDF2 복호화(스레드로 위임), KIS 클라이언트 생성, Redis/DB 리스크 상태 복원은
모두 락 밖에서 수행해 이벤트 루프 직렬화를 피한다. 동시에 같은 user_id 로 호출되면
중복 조립이 일어날 수 있으나(락 밖 조립) 마지막에 dict 갱신만 락으로 보호하고
먼저 캐시된 인스턴스를 우선한다.
"""
cached = self._contexts.get(user_id)
if cached is not None:
return cached
result = await db.execute(
select(UserKisCredential).where(UserKisCredential.user_id == user_id)
)
cred = result.scalar_one_or_none()
if cred is None or not cred.is_enabled:
logger.debug(f"[UserContextRegistry] user={user_id} 자격증명 없음/비활성 — None 반환")
return None
try:
app_key = await asyncio.to_thread(decrypt, cred.app_key_enc, key_version=cred.key_version)
app_secret = await asyncio.to_thread(decrypt, cred.app_secret_enc, key_version=cred.key_version)
account_no = await asyncio.to_thread(decrypt, cred.account_no_enc, key_version=cred.key_version)
except CredentialDecryptError as e:
logger.error(f"[UserContextRegistry] user={user_id} 자격증명 복호화 실패: {e}")
raise HTTPException(
status_code=400, detail="자격증명 복호화 실패 — 서버 키 변경 여부 확인",
) from e
try:
kis = await kis_client_pool.get_or_create(
user_id=user_id,
app_key=app_key,
app_secret=app_secret,
account_no=account_no,
mode=cred.mode,
)
except Exception as e:
logger.error(f"[UserContextRegistry] user={user_id} KIS 클라이언트 생성 실패: {e}")
return None
mdd = create_mdd_manager_for_user(user_id)
loss_tracker = create_loss_tracker_for_user(user_id)
await load_user_risk_state(user_id, mdd, loss_tracker, db)
ctx = UserContext(
user_id=user_id,
username="",
mode=cred.mode,
kis=kis,
mdd=mdd,
loss_tracker=loss_tracker,
auto_trade_enabled=bool(cred.auto_trade_enabled),
is_legacy=False,
)
async with self._lock:
existing = self._contexts.get(user_id)
if existing is not None:
return existing
self._contexts[user_id] = ctx
return ctx
# ── legacy(단일 계좌) 컨텍스트 ────────────────────────────────────────
async def legacy(self, db: AsyncSession | None = None) -> UserContext:
"""기존 전역 싱글톤(`korea_investment_api`/`mdd_manager`/`loss_tracker`)을 감싼 컨텍스트.
owner id는 60초 TTL로 재확인한다(관리자 계정이 나중에 생기는 초기 부팅 구간 대응).
owner 가 아직 없으면(관리자 미존재) `user_id=None`으로 반환하고 TTL 캐시를 걸지
않는다(M1) — `users.id=0`은 존재하지 않는 FK 위반 값이라 0 대신 None 으로 통일한다.
"""
now = time.monotonic()
if (
self._legacy_ctx is not None
and self._legacy_owner_confirmed
and (now - self._legacy_checked_at) < _LEGACY_OWNER_TTL_SEC
):
return self._legacy_ctx
async with self._legacy_lock:
now = time.monotonic()
if (
self._legacy_ctx is not None
and self._legacy_owner_confirmed
and (now - self._legacy_checked_at) < _LEGACY_OWNER_TTL_SEC
):
return self._legacy_ctx
owner_id: int | None = None
if db is not None:
try:
owner = await get_legacy_owner(db)
if owner is not None:
owner_id = owner.id
except Exception as e:
logger.warning(f"[UserContextRegistry] legacy owner 조회 실패: {e}")
from app.services.korea_investment_api import korea_investment_api
from app.services.mdd_manager import mdd_manager
from app.services.loss_tracker import loss_tracker
self._legacy_ctx = UserContext(
user_id=owner_id,
username="legacy",
mode=settings.KIS_MODE,
kis=korea_investment_api,
mdd=mdd_manager,
loss_tracker=loss_tracker,
auto_trade_enabled=settings.AUTO_TRADE_ENABLED,
is_legacy=True,
)
self._legacy_owner_id = owner_id
self._legacy_owner_confirmed = owner_id is not None
self._legacy_checked_at = now
return self._legacy_ctx
# ── 인증 사용자 → 컨텍스트 해석 ───────────────────────────────────────
async def resolve(self, current_user: CurrentUser, db: AsyncSession) -> UserContext:
"""`CurrentUser` → `UserContext`. 실패 시 400 HTTPException."""
legacy_ctx = await self.legacy(db)
use_legacy = (
not settings.MULTI_TENANT_ENABLED
or current_user.is_api_key
or (current_user.id is not None and current_user.id == legacy_ctx.user_id)
)
if use_legacy:
return legacy_ctx
if current_user.id is None:
raise HTTPException(status_code=400, detail="KIS 계좌가 등록·검증되지 않았습니다. 내 계좌 설정에서 등록하세요")
ctx = await self.get(current_user.id, db)
if ctx is None:
raise HTTPException(status_code=400, detail="KIS 계좌가 등록·검증되지 않았습니다. 내 계좌 설정에서 등록하세요")
return ctx
# ── 2단계 스케줄러용: 활성 사용자 전체 순회 ───────────────────────────
async def iter_active(self, db: AsyncSession) -> list[UserContext]:
"""`is_enabled and auto_trade_enabled` 인 모든 사용자의 컨텍스트 (60초 TTL 캐시).
지금은 조립 로직만 제공 — 실제 2단계 스케줄러 연결은 이번 범위 밖.
"""
now = time.monotonic()
if self._active_cache is not None and (now - self._active_cached_at) < _ACTIVE_LIST_TTL_SEC:
return self._active_cache
result = await db.execute(
select(UserKisCredential).where(
UserKisCredential.is_enabled.is_(True),
UserKisCredential.auto_trade_enabled.is_(True),
)
)
creds = result.scalars().all()
contexts: list[UserContext] = []
for cred in creds:
try:
ctx = await self.get(cred.user_id, db)
except Exception as e:
logger.error(f"[UserContextRegistry] iter_active: user={cred.user_id} 조립 실패: {e}")
ctx = None
if ctx is not None:
contexts.append(ctx)
self._active_cache = contexts
self._active_cached_at = now
return contexts
# ── 캐시 무효화 ───────────────────────────────────────────────────────
async def invalidate(self, user_id: int) -> None:
"""자격증명 변경/삭제 시 credentials API가 호출 — 컨텍스트 캐시 + KIS 풀 정리."""
async with self._lock:
self._contexts.pop(user_id, None)
await kis_client_pool.invalidate(user_id)
self._active_cache = None
async def invalidate_all(self) -> None:
async with self._lock:
self._contexts.clear()
self._active_cache = None
# 모듈 싱글톤
user_context_registry = UserContextRegistry()
async def get_user_context(
user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> UserContext:
"""FastAPI 의존성 — 세션/API 키 인증 사용자를 `UserContext`로 해석."""
return await user_context_registry.resolve(user, db)

View File

@@ -0,0 +1,71 @@
"""사용자 스코프 리스크 매니저 팩토리 (설계 문서 §5.6).
`UserContextRegistry`(dev-team/trading-agent 조립 담당)가 사용자 컨텍스트를
만들 때 이 팩토리로 `MDDManager`/`ConsecutiveLossTracker` 인스턴스를 새로
생성하고 영속화 상태(Redis `mdd:state:{user_id}`, `stock_bans_v2`)를 복원한다.
기존 전역 싱글톤(`app.services.mdd_manager.mdd_manager`,
`app.services.loss_tracker.loss_tracker`)은 legacy 컨텍스트 전용으로 그대로
남겨두고 이 모듈에서 재사용하지 않는다 — 여러 사용자가 동일 인스턴스를 공유하면
peak_balance/연속손실 카운트가 뒤섞이기 때문이다.
"""
import logging
from app.services.mdd_manager import MDDManager
from app.services.loss_tracker import ConsecutiveLossTracker
logger = logging.getLogger(__name__)
def create_mdd_manager_for_user(user_id: int) -> MDDManager:
"""사용자 전용 `MDDManager` 신규 인스턴스 생성.
peak_balance/is_paused 등은 기본값(0/False)에서 시작 — 실제 복원은
`load_user_risk_state()`가 `load_from_redis_v2(user_id)`로 수행한다.
"""
return MDDManager()
def create_loss_tracker_for_user(user_id: int) -> ConsecutiveLossTracker:
"""사용자 전용 `ConsecutiveLossTracker` 신규 인스턴스 생성.
`ban_count`/`ban_calendar_days`는 전역 설정(§CLAUDE.md 5.1: 3연패/42일)을
그대로 따른다 — 사용자별 오버라이드는 이번 범위 밖.
인스턴스의 `user_id` 속성을 세팅해 두면 호출 측(스케줄러/엔드포인트)이
`ctx.loss_tracker.save_to_db_v2(ctx.loss_tracker.user_id, stock_code)`처럼
컨텍스트에서 user_id를 다시 꺼낼 필요 없이 인스턴스에서 바로 참조할 수 있다.
"""
try:
from app.core.config import settings
tracker = ConsecutiveLossTracker(
ban_count=settings.CONSECUTIVE_LOSS_BAN_COUNT,
ban_calendar_days=42,
)
except Exception:
tracker = ConsecutiveLossTracker()
tracker.user_id = user_id
return tracker
async def load_user_risk_state(
ctx_user_id: int,
mdd: MDDManager,
tracker: ConsecutiveLossTracker,
db,
) -> None:
"""사용자 컨텍스트 생성 직후 1회 호출 — Redis(MDD) + DB(ban) 상태 복원.
두 복원 모두 실패해도 예외를 전파하지 않는다(각 `_v2` 메서드 내부에서
이미 try/except 처리됨) — 신규 사용자는 그냥 초기 상태로 시작한다.
"""
try:
restored = await mdd.load_from_redis_v2(ctx_user_id)
if not restored:
logger.debug(f"[risk_factory] user={ctx_user_id} MDD 상태 없음 — 초기값으로 시작")
except Exception as e:
logger.warning(f"[risk_factory] user={ctx_user_id} MDD 복원 실패: {e}")
try:
await tracker.load_from_db_v2(db, ctx_user_id)
except Exception as e:
logger.warning(f"[risk_factory] user={ctx_user_id} loss_tracker 복원 실패: {e}")