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

@@ -17,6 +17,15 @@ Supported task types:
- upsert_portfolio : dict → Portfolio INSERT/UPDATE (strategy_type 포함)
- upsert_active_trade : dict → ActiveTrade UPSERT (strategy_name 포함)
- close_active_trade : dict → ActiveTrade 상태를 'closed'로 변경
- upsert_stock_ban_v2 : dict → StockBanV2 UPSERT (user_id, stock_code 스코프 — 멀티테넌트)
멀티테넌트(사용자 스코프) 확장 (docs/multi_tenant_design.md §3.4):
- insert_trade_history / update_trade_history(_by_order_id) / upsert_portfolio /
upsert_active_trade / close_active_trade / insert_trade_journal 는 payload
data["user_id"] 가 있으면 조회 필터·생성 컬럼에 반영하고, 없으면(None) 기존과
100% 동일하게 동작한다(하위 호환). 새 task 타입을 추가하지 않는다.
- stock_bans 는 PK가 stock_code 단일이라 사용자 스코프로 확장 불가 → 신규 테이블
stock_bans_v2(user_id, stock_code UNIQUE 쌍) + 신규 task upsert_stock_ban_v2 사용.
"""
import asyncio
import json
@@ -183,6 +192,25 @@ async def enqueue_stock_ban_upsert(stock_code: str, consecutive_losses: int, ban
})
async def enqueue_stock_ban_upsert_v2(
user_id: int, stock_code: str, consecutive_losses: int, ban_until
) -> None:
"""[v2] StockBanV2 UPSERT 를 큐에 등록 (사용자 스코프 loss_tracker에서 호출).
stock_bans(PK=stock_code, 단일 계정 전제)와 달리 stock_bans_v2 는
UNIQUE(user_id, stock_code) 이므로 동일 종목을 사용자별로 독립 추적한다.
"""
await db_task_queue.put({
"type": "upsert_stock_ban_v2",
"data": {
"user_id": user_id,
"stock_code": stock_code,
"consecutive_losses": consecutive_losses,
"ban_until": ban_until,
}
})
async def enqueue_portfolio_upsert(data: dict) -> None:
"""Portfolio INSERT/UPDATE 를 큐에 등록.
@@ -211,6 +239,20 @@ async def enqueue_close_active_trade(stock_code: str, strategy_name: str | None
})
async def enqueue_close_active_trade_v2(
user_id: int, stock_code: str, strategy_name: str | None = None
) -> None:
"""[v2] 사용자 스코프 ActiveTrade 를 'closed' 상태로 변경하는 태스크를 큐에 등록.
close_active_trade 와 동일 task type("close_active_trade")을 재사용한다 —
핸들러가 data.get("user_id")로 필터를 추가하므로 새 타입이 필요 없다.
"""
await db_task_queue.put({
"type": "close_active_trade",
"data": {"stock_code": stock_code, "strategy_name": strategy_name, "user_id": user_id},
})
async def enqueue_update_trade_history_by_order_id(order_id: str, updates: dict) -> None:
"""KIS 주문번호(order_id)로 TradeHistory UPDATE 를 큐에 등록.
@@ -288,10 +330,12 @@ async def db_writer_worker() -> None:
# ── 2. TradeHistory UPDATE (체결 확인 후 슬리피지/수량 반영) ──
elif task_type == "update_trade_history":
trade_id = data.get("id")
user_id = data.get("user_id")
if trade_id:
row = await session.execute(
select(TradeHistory).where(TradeHistory.id == trade_id)
)
query = select(TradeHistory).where(TradeHistory.id == trade_id)
if user_id is not None:
query = query.where(TradeHistory.user_id == user_id)
row = await session.execute(query)
trade = row.scalar_one_or_none()
if trade:
for key, val in data.items():
@@ -307,10 +351,12 @@ async def db_writer_worker() -> None:
# ── 2b. TradeHistory UPDATE by order_id (KIS 주문번호 기준) ──
elif task_type == "update_trade_history_by_order_id":
order_id = data.get("order_id")
user_id = data.get("user_id")
if order_id:
row = await session.execute(
select(TradeHistory).where(TradeHistory.order_id == order_id)
)
query = select(TradeHistory).where(TradeHistory.order_id == order_id)
if user_id is not None:
query = query.where(TradeHistory.user_id == user_id)
row = await session.execute(query)
trade = row.scalar_one_or_none()
if trade:
for key, val in data.items():
@@ -350,10 +396,12 @@ async def db_writer_worker() -> None:
# ── 4. StockBan UPSERT ────────────────────────────────────────
elif task_type == "upsert_stock_ban":
from app.models.models import StockBan
from app.core.dt_utils import to_naive_utc
stock_code = data.get("stock_code")
consecutive_losses = data.get("consecutive_losses")
ban_until = data.get("ban_until")
# stock_bans.ban_until은 tz-naive 컬럼 — tz-aware 값이 들어와도 안전하게 저장.
ban_until = to_naive_utc(data.get("ban_until"))
result = await session.execute(select(StockBan).where(StockBan.stock_code == stock_code))
record = result.scalar_one_or_none()
@@ -377,6 +425,42 @@ async def db_writer_worker() -> None:
ban_until.isoformat() if ban_until else "None",
)
# ── 4v2. StockBanV2 UPSERT (user_id, stock_code 스코프) ────────
elif task_type == "upsert_stock_ban_v2":
from app.models.models import StockBanV2
from sqlalchemy.dialects.postgresql import insert as pg_insert
from app.core.dt_utils import to_naive_utc
user_id = data.get("user_id")
stock_code = data.get("stock_code")
consecutive_losses = data.get("consecutive_losses")
# stock_bans_v2.ban_until도 tz-naive 컬럼 — tz-aware 값이 들어와도 안전하게 저장.
ban_until = to_naive_utc(data.get("ban_until"))
# PostgreSQL INSERT ... ON CONFLICT DO UPDATE — 여러 워커/사용자
# 사이클이 동시에 같은 (user_id, stock_code)를 갱신해도 경쟁 안전.
stmt = pg_insert(StockBanV2).values(
user_id=user_id,
stock_code=stock_code,
consecutive_losses=consecutive_losses,
ban_until=ban_until,
)
stmt = stmt.on_conflict_do_update(
constraint="uq_stock_bans_v2_user_stock",
set_={
"consecutive_losses": stmt.excluded.consecutive_losses,
"ban_until": stmt.excluded.ban_until,
"updated_at": datetime.utcnow(),
},
)
await session.execute(stmt)
await session.commit()
logger.debug(
"✅ [Worker] StockBanV2 upsert: user=%s %s (losses=%s, ban_until=%s)",
user_id, stock_code, consecutive_losses,
ban_until.isoformat() if ban_until else "None",
)
# ── 4b. News INSERT (중복 URL 무시) ────────────────────────────
elif task_type == "insert_news":
from app.models.models import News
@@ -417,9 +501,11 @@ async def db_writer_worker() -> None:
from app.models.models import Portfolio
stock_code = data.get("stock_code")
result = await session.execute(
select(Portfolio).where(Portfolio.stock_code == stock_code)
)
user_id = data.get("user_id")
query = select(Portfolio).where(Portfolio.stock_code == stock_code)
if user_id is not None:
query = query.where(Portfolio.user_id == user_id)
result = await session.execute(query)
record = result.scalar_one_or_none()
if record is None:
@@ -443,6 +529,7 @@ async def db_writer_worker() -> None:
stock_code = data.get("stock_code")
strategy_name = data.get("strategy_name")
user_id = data.get("user_id")
query = select(ActiveTrade).where(
ActiveTrade.stock_code == stock_code,
@@ -450,9 +537,17 @@ async def db_writer_worker() -> None:
)
if strategy_name:
query = query.where(ActiveTrade.strategy_name == strategy_name)
result = await session.execute(query)
record = result.scalar_one_or_none()
if user_id is not None:
query = query.where(ActiveTrade.user_id == user_id)
result = await session.execute(query)
record = result.scalar_one_or_none()
else:
# legacy(user_id=None) 경로: 사용자 다중화로 동일 종목 active
# 행이 2건 이상 존재할 수 있어 scalar_one_or_none() 이
# MultipleResultsFound 를 던질 수 있음 → 가장 최근 행 우선.
query = query.order_by(ActiveTrade.id.desc()).limit(1)
result = await session.execute(query)
record = result.scalars().first()
if record is None:
# remaining_pct_multiply는 신규 생성 시 무시
@@ -486,6 +581,7 @@ async def db_writer_worker() -> None:
stock_code = data.get("stock_code")
strategy_name = data.get("strategy_name")
user_id = data.get("user_id")
query = select(ActiveTrade).where(
ActiveTrade.stock_code == stock_code,
@@ -493,9 +589,17 @@ async def db_writer_worker() -> None:
)
if strategy_name:
query = query.where(ActiveTrade.strategy_name == strategy_name)
result = await session.execute(query)
record = result.scalar_one_or_none()
if user_id is not None:
query = query.where(ActiveTrade.user_id == user_id)
result = await session.execute(query)
record = result.scalar_one_or_none()
else:
# legacy(user_id=None) 경로: 사용자 다중화로 동일 종목 active
# 행이 2건 이상 존재할 수 있어 scalar_one_or_none() 이
# MultipleResultsFound 를 던질 수 있음 → 가장 최근 행 우선.
query = query.order_by(ActiveTrade.id.desc()).limit(1)
result = await session.execute(query)
record = result.scalars().first()
if record:
record.status = "closed"
@@ -543,6 +647,7 @@ async def db_writer_worker() -> None:
"upsert_stock_analysis",
"bulk_upsert_stock_analysis",
"upsert_stock_ban",
"upsert_stock_ban_v2",
"upsert_portfolio",
"upsert_active_trade",
"close_active_trade",

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

View File

@@ -11,10 +11,17 @@ from typing import Dict, Optional
logger = logging.getLogger(__name__)
# tz-aware/naive 경계 정규화 — app/core/dt_utils 참고 (db_worker.py와 공유)
from app.core.dt_utils import to_naive_utc as _to_naive_utc, to_aware_utc as _to_aware_utc # noqa: E402
class ConsecutiveLossTracker:
"""종목별 연속 손실 추적 및 쿨링오프 관리"""
# 멀티테넌트: risk_factory.create_loss_tracker_for_user()가 인스턴스 생성 후
# 세팅하는 소유자 user_id. 레거시 싱글톤은 None으로 유지(전역 stock_bans 사용).
user_id: Optional[int] = None
def __init__(self, ban_count: int = 3, ban_calendar_days: int = 42):
"""
Args:
@@ -67,15 +74,80 @@ class ConsecutiveLossTracker:
return
# 큐에 UPSERT 작업 등록 (db_worker가 비동기적으로 처리)
# ban_until 컬럼은 tz-naive이므로 저장 직전 naive-UTC로 정규화한다.
await enqueue_stock_ban_upsert(
stock_code=stock_code,
consecutive_losses=entry["consecutive_losses"],
ban_until=entry["ban_until"],
ban_until=_to_naive_utc(entry["ban_until"]),
)
logger.debug(f"loss_tracker DB 저장 큐 등록: {stock_code}")
except Exception as e:
logger.error(f"loss_tracker DB 저장 큐 등록 실패 ({stock_code}): {e}")
async def save_to_db_v2(self, user_id: int, stock_code: str = None):
"""[v2] 사용자 스코프 인메모리 상태를 stock_bans_v2 에 영속화.
save_to_db()와 동일 패턴이지만 db_worker의 upsert_stock_ban_v2 task로
(user_id, stock_code) 단위 UPSERT한다. 기존 save_to_db()는 불변.
"""
if stock_code is None:
return
try:
from app.services.db_worker import enqueue_stock_ban_upsert_v2
entry = self._tracker.get(stock_code)
if entry is None:
return
# stock_bans_v2.ban_until 도 tz-naive 컬럼 — 저장 직전 naive-UTC로 정규화.
await enqueue_stock_ban_upsert_v2(
user_id=user_id,
stock_code=stock_code,
consecutive_losses=entry["consecutive_losses"],
ban_until=_to_naive_utc(entry["ban_until"]),
)
logger.debug(f"loss_tracker_v2 DB 저장 큐 등록: user={user_id} {stock_code}")
except Exception as e:
logger.error(f"loss_tracker_v2 DB 저장 큐 등록 실패 (user={user_id}, {stock_code}): {e}")
async def load_from_db_v2(self, db, user_id: int):
"""[v2] 앱 시작/컨텍스트 생성 시 stock_bans_v2 에서 사용자 스코프 ban 상태 복원."""
try:
from sqlalchemy import select
from app.models.models import StockBanV2
result = await db.execute(select(StockBanV2).where(StockBanV2.user_id == user_id))
records = result.scalars().all()
now = datetime.now(timezone.utc)
loaded = 0
for rec in records:
# DB의 ban_until은 tz-naive → tz-aware now와 비교하기 전 정규화.
ban_until = _to_aware_utc(rec.ban_until)
if ban_until and now >= ban_until:
ban_until = None
consecutive_losses = 0
else:
consecutive_losses = rec.consecutive_losses
self._tracker[rec.stock_code] = {
"consecutive_losses": consecutive_losses,
"ban_until": ban_until,
}
if ban_until:
loaded += 1
logger.info(
f"ban_v2 복원: user={user_id} {rec.stock_code} → "
f"{ban_until.strftime('%Y-%m-%d')}까지 진입 금지"
)
logger.info(
f"loss_tracker_v2 DB 로드 완료: user={user_id} {len(records)}건 중 {loaded}건 활성 ban"
)
except Exception:
logger.exception(f"loss_tracker_v2 DB 로드 실패 (user={user_id})")
async def load_from_db(self, db):
"""앱 시작 시 DB에서 ban 상태 복원"""
try:
@@ -88,8 +160,8 @@ class ConsecutiveLossTracker:
now = datetime.now(timezone.utc)
loaded = 0
for rec in records:
# 만료된 ban은 초기화
ban_until = rec.ban_until
# 만료된 ban은 초기화. DB의 ban_until은 tz-naive → 비교 전 정규화.
ban_until = _to_aware_utc(rec.ban_until)
if ban_until and now >= ban_until:
ban_until = None
consecutive_losses = 0
@@ -107,8 +179,8 @@ class ConsecutiveLossTracker:
)
logger.info(f"loss_tracker DB 로드 완료: {len(records)}건 중 {loaded}건 활성 ban")
except Exception as e:
logger.error(f"loss_tracker DB 로드 실패: {e}")
except Exception:
logger.exception("loss_tracker DB 로드 실패")
def is_banned(self, stock_code: str) -> bool:
"""해당 종목이 진입 금지 상태인지 확인"""

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

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}")

View File

@@ -208,6 +208,111 @@ async def safe_place_order(
await _release_order_lock(stock_code, order_type)
# ── [멀티테넌트 1단계 §5.8] 사용자 스코프 주문 락/래퍼 ────────────────────────
# 기존 safe_place_order/_acquire_order_lock/_auto_cancel_if_pending는 시그니처
# 불변 유지. 사용자별 계좌(ctx.kis)로 주문하는 경로는 아래 _v2 함수를 사용한다.
async def _acquire_order_lock_v2(user_id, stock_code: str, order_type: str) -> bool:
"""Redis 분산락 획득 — 사용자 네임스페이스 키(`order_lock:{user_id}:{code}:{type}`).
구조는 `_acquire_order_lock`과 동일(SETNX+TTL 60초). legacy 컨텍스트(user_id가
None 또는 legacy owner id)도 이 함수를 쓰면 키에 user_id가 섞이므로, legacy
경로는 기존 `_acquire_order_lock`(user_id 없는 키)을 그대로 사용해야 한다.
"""
try:
from app.core.redis import get_redis
client = get_redis()
if not client:
return True
key = f"order_lock:{user_id}:{stock_code}:{order_type}"
result = await client.set(key, "1", nx=True, ex=_ORDER_LOCK_TTL)
return result is True
except Exception as e:
logger.debug(f"[order_lock_v2] user_id={user_id} 획득 오류 (패스): {e}")
return True
async def _release_order_lock_v2(user_id, stock_code: str, order_type: str) -> None:
"""Redis 분산락 해제 — `_acquire_order_lock_v2`와 짝을 이루는 사용자 네임스페이스 키."""
try:
from app.core.redis import delete_key
await delete_key(f"order_lock:{user_id}:{stock_code}:{order_type}")
except Exception:
pass
async def safe_place_order_v2(
ctx, # UserContext (순환 임포트 회피 — 타입은 app.services.tenant.context.UserContext)
stock_code: str,
order_type: str,
quantity: int,
price: float | None = None,
is_domestic: bool = True,
exchange_code: str = "NASD",
) -> dict:
"""`safe_place_order`의 사용자 스코프 버전 (설계 문서 §5.8).
- `ctx.is_legacy`면 기존 전역 경로(`_acquire_order_lock`/`_release_order_lock`,
`_get_order_semaphore(stock_code)`)를 그대로 사용 — swing/daytrading exit_service
등 기존 스케줄러가 잡는 락과 같은 키여야 같은 계좌·종목 동시 매도가 직렬화된다.
사용자 컨텍스트(`is_legacy=False`)만 사용자 네임스페이스 키(`{user_id}:{code}`)를 쓴다.
- 세마포어: `_get_order_semaphore(f"{ctx.user_id}:{stock_code}")` — 기존 함수 재사용,
키 문자열만 사용자 네임스페이스로 바꿈(시그니처 불변).
- Redis 락: `_acquire_order_lock_v2`/`_release_order_lock_v2` (legacy는 v1 그대로).
- 주문 실행: `ctx.kis.place_order(...)`/`ctx.kis.place_overseas_order(...)`.
- 반환 형식은 `safe_place_order`와 동일.
"""
if ctx.is_legacy:
semaphore_key = stock_code
lock_acquired = await _acquire_order_lock(stock_code, order_type)
else:
semaphore_key = f"{ctx.user_id}:{stock_code}"
lock_acquired = await _acquire_order_lock_v2(ctx.user_id, stock_code, order_type)
if not lock_acquired:
logger.warning(f"[SAFE_ORDER_V2] user_id={ctx.user_id} {stock_code} {order_type} — 분산락 획득 실패 (이미 처리 중)")
return {"rt_cd": "-1", "msg1": "order_lock_busy"}
try:
async with _get_order_semaphore(semaphore_key):
if is_domestic:
result = await ctx.kis.place_order(
stock_code=stock_code,
order_type=order_type,
quantity=quantity,
price=price,
)
else:
result = await ctx.kis.place_overseas_order(
symbol=stock_code,
order_type=order_type,
quantity=quantity,
price=price or 0,
exchange_code=exchange_code,
)
return result or {"rt_cd": "-1", "msg1": "no_response"}
except Exception as e:
logger.error(f"[SAFE_ORDER_V2] user_id={ctx.user_id} {stock_code} {order_type} 실패: {e}")
return {"rt_cd": "-1", "msg1": str(e)}
finally:
if ctx.is_legacy:
await _release_order_lock(stock_code, order_type)
else:
await _release_order_lock_v2(ctx.user_id, stock_code, order_type)
async def _auto_cancel_if_pending_v2(ctx, order_id: str, stock_code: str, quantity: int, delay: int = 60) -> None:
"""`_auto_cancel_if_pending`의 사용자 스코프 버전 — `ctx.kis.cancel_order`를 사용."""
await asyncio.sleep(delay)
try:
result = await ctx.kis.cancel_order(order_id, quantity)
if result.get("rt_cd") == "0":
logger.info(f"[AUTO_CANCEL_V2] user_id={ctx.user_id} 미체결 자동 취소 완료: {stock_code} order_id={order_id}")
else:
logger.debug(f"[AUTO_CANCEL_V2] user_id={ctx.user_id} {stock_code} — {result.get('msg1')} (이미 체결됐을 수 있음)")
except Exception as e:
logger.debug(f"[AUTO_CANCEL_V2] user_id={ctx.user_id} {stock_code} — {type(e).__name__}: {e}")
# ── 미체결 주문 1분 후 자동 취소 ─────────────────────────────────────────────
async def _auto_cancel_if_pending(order_id: str, stock_code: str, quantity: int, delay: int = 60) -> None:
"""delay초 후에도 PENDING 상태면 자동 취소.