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

@@ -29,7 +29,7 @@ from datetime import datetime
import bcrypt
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import func, select
from sqlalchemy import func, select, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -89,6 +89,19 @@ class UserOut(BaseModel):
last_login_at: datetime | None
class TenantMigrateOut(BaseModel):
legacy_owner: str | None
credentials: str # created | exists | skipped_no_env | skipped_no_encryption
backfilled: dict[str, int]
class TenantStatusOut(BaseModel):
legacy_owner: str | None
multi_tenant_enabled: bool
credentials_registered: int
null_user_id_counts: dict[str, int]
# ── 헬퍼 ─────────────────────────────────────────────────────────────────
def _hash_password(password: str) -> str:
# passlib 1.7.4 는 bcrypt>=4.1 과 호환되지 않으므로 bcrypt 를 직접 사용 (72바이트 제한)
@@ -274,3 +287,98 @@ async def revoke_user(user_id: int, admin: str = Depends(require_admin), db: Asy
await db.commit()
logger.info(f"[Auth] 계정 비활성화 user={user.username} by={admin}")
return UserOut.model_validate(user, from_attributes=True)
# ── 멀티테넌트 이관 관리자 API (설계문서 §4, §5.10) ─────────────────────────
# `ensure_bootstrap_admin()`(app/services/tenant/bootstrap.py, main.py lifespan 에서
# 앱 기동 시 1회 자동 실행)와 동일한 이관/백필 로직을 관리자가 수동으로 재실행할 수
# 있게 노출한다. 예: 관리자 계정이 앱 기동 이후 처음 생성된 경우(부트스트랩 시점에는
# legacy owner 가 없어 자동 이관이 스킵됨) 이 API로 지연 이관을 수행한다.
@router.post(
"/admin/tenant/migrate-legacy",
response_model=TenantMigrateOut,
dependencies=[Depends(require_admin)],
)
async def migrate_legacy_tenant(db: AsyncSession = Depends(get_db)):
""".env KIS 자격증명을 legacy owner 로 이관 + user_id NULL 행 백필 (idempotent)."""
from app.core.crypto import is_encryption_configured
from app.models.models import UserKisCredential
from app.services.tenant.bootstrap import (
_BACKFILL_TABLES,
_backfill_legacy_owner_ids,
_get_legacy_owner,
_seed_legacy_kis_credential,
)
owner = await _get_legacy_owner(db)
if owner is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="관리자 계정이 없습니다. 먼저 회원가입하세요.")
existing = (
await db.execute(select(UserKisCredential).where(UserKisCredential.user_id == owner.id))
).scalar_one_or_none()
if existing is not None:
cred_result = "exists"
elif not (
settings.KOREA_INVESTMENT_APP_KEY
and settings.KOREA_INVESTMENT_APP_SECRET
and settings.KOREA_INVESTMENT_ACCOUNT_NUMBER
):
cred_result = "skipped_no_env"
elif not is_encryption_configured():
cred_result = "skipped_no_encryption"
else:
await _seed_legacy_kis_credential(db, owner)
cred_result = "created"
# 백필 대상 행 수는 백필 "직전" 상태를 반환(백필 후에는 0이 되므로 결과 확인용으로 직전 값 사용)
# M8: 문장별 SAVEPOINT — 한 테이블(컬럼 미존재 등)의 SELECT 실패가 PostgreSQL
# 트랜잭션을 InFailedSqlTransaction으로 만들어 이후 테이블 카운트까지 연쇄로
# 0 오보되는 것을 방지한다.
backfilled: dict[str, int] = {}
for table in _BACKFILL_TABLES:
try:
async with db.begin_nested():
backfilled[table] = (
await db.execute(text(f"SELECT COUNT(*) FROM {table} WHERE user_id IS NULL"))
).scalar() or 0
except Exception:
backfilled[table] = 0
await _backfill_legacy_owner_ids(db, owner.id)
logger.info(
f"[Auth] 레거시 이관 실행 owner={owner.username} credentials={cred_result} backfilled={backfilled}"
)
return TenantMigrateOut(legacy_owner=owner.username, credentials=cred_result, backfilled=backfilled)
@router.get(
"/admin/tenant/status",
response_model=TenantStatusOut,
dependencies=[Depends(require_admin)],
)
async def tenant_status(db: AsyncSession = Depends(get_db)):
"""legacy owner, MULTI_TENANT_ENABLED, 자격증명 등록 수, 테이블별 user_id NULL 행 수."""
from app.models.models import UserKisCredential
from app.services.tenant.bootstrap import _BACKFILL_TABLES, _get_legacy_owner
owner = await _get_legacy_owner(db)
cred_count = (await db.execute(select(func.count()).select_from(UserKisCredential))).scalar() or 0
null_counts: dict[str, int] = {}
for table in _BACKFILL_TABLES:
try:
null_counts[table] = (
await db.execute(text(f"SELECT COUNT(*) FROM {table} WHERE user_id IS NULL"))
).scalar() or 0
except Exception:
null_counts[table] = 0
return TenantStatusOut(
legacy_owner=owner.username if owner else None,
multi_tenant_enabled=settings.MULTI_TENANT_ENABLED,
credentials_registered=cred_count,
null_user_id_counts=null_counts,
)

View File

@@ -0,0 +1,314 @@
"""사용자별 KIS(한국투자증권) 자격증명 관리 API (멀티테넌트 1단계, 설계문서 §5.3).
- 저장/삭제는 "조회 → upsert/삭제 → 응답"이 한 요청 안에서 원자적으로 끝나야 하므로
auth.py 의 register/login 과 같은 성격으로 엔드포인트에서 직접 세션을 커밋한다
(CLAUDE.md §3.2 예외 — 실시간 계층이 아님).
- 평문 app_key/app_secret/account_no 는 어떤 응답·로그에도 노출하지 않는다
(app_key_hint·마스킹된 계좌번호만 노출).
- API 키(X-API-Key=SECRET_KEY) 호출자는 `get_current_user` 가 legacy owner 로
매핑하므로(§5.4), legacy owner 도 없는 최초 상태(`user.id is None`)에서만 400을
반환하고 그 외에는 정상적으로 legacy owner 본인의 자격증명을 다룬다.
"""
import asyncio
import hashlib
import logging
import time
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.crypto import CredentialDecryptError, current_key_version, decrypt, encrypt, is_encryption_configured
from app.core.database import get_db
from app.core.tenant_auth import CurrentUser, get_current_user
from app.models.models import UserKisCredential
from app.services.korea_investment_api import KoreaInvestmentAPI
from app.services.tenant.registry import user_context_registry
logger = logging.getLogger(__name__)
router = APIRouter()
_VERIFY_COOLDOWN_SEC = 60
# user_id -> 마지막 verify 시도 monotonic 시각 (인메모리, 프로세스 재시작 시 리셋되어도 무방)
_last_verify_attempt: dict[int, float] = {}
# ── 스키마 ────────────────────────────────────────────────────────────────
class CredentialsIn(BaseModel):
mode: str
app_key: str = Field(..., min_length=1)
app_secret: str = Field(..., min_length=1)
account_no: str = Field(..., min_length=1)
@field_validator("mode")
@classmethod
def _mode_valid(cls, v: str) -> str:
if v not in ("virtual", "real"):
raise ValueError("mode는 virtual 또는 real 이어야 합니다.")
return v
@field_validator("account_no")
@classmethod
def _account_no_valid(cls, v: str) -> str:
digits = v.replace("-", "")
if not digits.isdigit() or len(digits) != 10:
raise ValueError("계좌번호는 하이픈 제거 후 숫자 10자리(계좌 8자리+상품코드 2자리)여야 합니다.")
return digits
class CredentialsOut(BaseModel):
mode: str
app_key_hint: str
account_masked: str
is_enabled: bool
auto_trade_enabled: bool
last_verified_at: datetime | None
updated_at: datetime | None
decrypt_error: bool = False
class AutoTradeIn(BaseModel):
enabled: bool
class VerifyOut(BaseModel):
ok: bool
balance_summary: dict
verified_at: datetime
# ── 헬퍼 ─────────────────────────────────────────────────────────────────
def _require_user_id(user: CurrentUser) -> int:
if user.id is None:
raise HTTPException(status_code=400, detail="관리자 계정을 먼저 생성하세요.")
return user.id
async def _get_own(db: AsyncSession, user_id: int) -> UserKisCredential | None:
result = await db.execute(select(UserKisCredential).where(UserKisCredential.user_id == user_id))
return result.scalar_one_or_none()
def _mask_account(account_no_plain: str) -> str:
"""계좌 8자리 기준 마스킹: '******' + 뒤 2자리 (상품코드 제외)."""
acct = account_no_plain[:8]
return "******" + acct[-2:]
async def _to_out(cred: UserKisCredential) -> CredentialsOut:
"""계좌번호 마스킹 표시를 위해 복호화(M4: 스레드로 위임)한다.
복호화 실패(L2, 키 로테이션 실패 등)는 500 대신 `decrypt_error=true`로 응답해
마스킹 계좌 대신 원인 진단이 가능함을 알린다.
"""
try:
account_masked = _mask_account(
await asyncio.to_thread(decrypt, cred.account_no_enc, cred.key_version)
)
decrypt_error = False
except CredentialDecryptError as e:
logger.error("[KISCredentials] 계좌번호 복호화 실패 user_id=%s: %s", cred.user_id, e)
account_masked = "******"
decrypt_error = True
return CredentialsOut(
mode=cred.mode,
app_key_hint=(cred.app_key_hint or "") + "…",
account_masked=account_masked,
is_enabled=cred.is_enabled,
auto_trade_enabled=cred.auto_trade_enabled,
last_verified_at=cred.last_verified_at,
updated_at=cred.updated_at,
decrypt_error=decrypt_error,
)
def _pick_float(d: dict, keys: list[str]) -> float:
for k in keys:
v = d.get(k)
if v not in (None, ""):
try:
return float(v)
except (TypeError, ValueError):
continue
return 0.0
# ── 엔드포인트 ────────────────────────────────────────────────────────────
@router.get("/credentials", response_model=CredentialsOut)
async def get_credentials(
user: CurrentUser = Depends(get_current_user), db: AsyncSession = Depends(get_db)
):
user_id = _require_user_id(user)
cred = await _get_own(db, user_id)
if cred is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="등록된 KIS 자격증명이 없습니다.")
return await _to_out(cred)
@router.put("/credentials", response_model=CredentialsOut)
async def put_credentials(
body: CredentialsIn,
user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
user_id = _require_user_id(user)
if not is_encryption_configured():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="CREDENTIALS_ENCRYPTION_KEY/SECRET_KEY 미설정 또는 기본값 — 자격증명 암호화 저장 불가",
)
app_key_hash = hashlib.sha256(body.app_key.encode()).hexdigest()
dup = await db.execute(
select(UserKisCredential).where(
UserKisCredential.app_key_hash == app_key_hash,
UserKisCredential.user_id != user_id,
)
)
if dup.scalar_one_or_none() is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="이미 다른 사용자가 등록한 앱키입니다.")
now = datetime.utcnow()
cred = await _get_own(db, user_id)
is_new = cred is None
if is_new:
cred = UserKisCredential(user_id=user_id, auto_trade_enabled=False)
db.add(cred)
cred.mode = body.mode
cred.app_key_enc = encrypt(body.app_key)
cred.app_secret_enc = encrypt(body.app_secret)
cred.account_no_enc = encrypt(body.account_no)
cred.account_product_code = body.account_no[8:10]
cred.app_key_hash = app_key_hash
cred.app_key_hint = body.app_key[:6]
cred.is_enabled = False
cred.last_verified_at = None
cred.key_version = current_key_version()
cred.updated_at = now
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="이미 다른 사용자가 등록한 앱키입니다.")
await db.refresh(cred)
await user_context_registry.invalidate(user_id)
logger.info(
"[KISCredentials] 자격증명 %s user_id=%s mode=%s app_key=%s…",
"생성" if is_new else "교체", user_id, body.mode, body.app_key[:6],
)
return await _to_out(cred)
@router.delete("/credentials", status_code=status.HTTP_204_NO_CONTENT)
async def delete_credentials(
user: CurrentUser = Depends(get_current_user), db: AsyncSession = Depends(get_db)
):
user_id = _require_user_id(user)
cred = await _get_own(db, user_id)
if cred is not None:
await db.delete(cred)
await db.commit()
await user_context_registry.invalidate(user_id)
logger.info("[KISCredentials] 자격증명 삭제 user_id=%s", user_id)
@router.post("/credentials/verify", response_model=VerifyOut)
async def verify_credentials(
user: CurrentUser = Depends(get_current_user), db: AsyncSession = Depends(get_db)
):
user_id = _require_user_id(user)
cred = await _get_own(db, user_id)
if cred is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="등록된 KIS 자격증명이 없습니다.")
now_mono = time.monotonic()
last = _last_verify_attempt.get(user_id)
if last is not None:
remaining = _VERIFY_COOLDOWN_SEC - (now_mono - last)
if remaining > 0:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"{int(remaining) + 1}초 후 다시 시도하세요.",
)
_last_verify_attempt[user_id] = now_mono
try:
app_key = await asyncio.to_thread(decrypt, cred.app_key_enc, cred.key_version)
app_secret = await asyncio.to_thread(decrypt, cred.app_secret_enc, cred.key_version)
account_no = await asyncio.to_thread(decrypt, cred.account_no_enc, cred.key_version)
except CredentialDecryptError as e:
logger.error("[KISCredentials] verify 복호화 실패 user_id=%s: %s", user_id, e)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="자격증명 복호화 실패 — 서버 키 변경 여부 확인")
temp_kis = KoreaInvestmentAPI.for_user(
user_id=user_id, app_key=app_key, app_secret=app_secret, account_no=account_no, mode=cred.mode,
)
try:
balance = await temp_kis.get_balance()
except Exception as e:
# L5: KIS 예외 원문 대신 예외 타입명만 노출(방어적 — 현재 KIS 오류 본문에 키는 없음).
logger.warning("[KISCredentials] verify 실패 user_id=%s: %s", user_id, e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"KIS 연결 확인 실패: {type(e).__name__} — 연결 실패",
)
finally:
await temp_kis.close()
if balance.get("rt_cd") != "0":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=balance.get("msg1") or "KIS 잔고 조회 실패",
)
output2 = balance.get("output2") or {}
if isinstance(output2, list):
output2 = output2[0] if output2 else {}
balance_summary = {
"available_cash": _pick_float(output2, ["dnca_tot_amt", "d2_deposit", "nass_amt"]),
"total_assets": _pick_float(output2, ["tot_evlu_amt", "tot_evlu_amt_smtl"]),
"stock_value": _pick_float(output2, ["scts_evlu_amt", "evlu_amt_smtl_amt"]),
}
now = datetime.utcnow()
cred.last_verified_at = now
cred.is_enabled = True
cred.updated_at = now
await db.commit()
logger.info("[KISCredentials] verify 성공 user_id=%s mode=%s", user_id, cred.mode)
return VerifyOut(ok=True, balance_summary=balance_summary, verified_at=now)
@router.patch("/auto-trade", response_model=CredentialsOut)
async def patch_auto_trade(
body: AutoTradeIn,
user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
user_id = _require_user_id(user)
cred = await _get_own(db, user_id)
if cred is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="등록된 KIS 자격증명이 없습니다.")
if body.enabled and not cred.is_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="먼저 연결 테스트(verify)를 통과해야 자동매매를 활성화할 수 있습니다.",
)
cred.auto_trade_enabled = body.enabled
cred.updated_at = datetime.utcnow()
await db.commit()
# H3: 캐시된 UserContext.auto_trade_enabled 스냅샷이 갱신되도록 무효화(다음 호출에서 재조립).
await user_context_registry.invalidate(user_id)
logger.info("[KISCredentials] auto_trade_enabled=%s user_id=%s", body.enabled, user_id)
return await _to_out(cred)

View File

@@ -404,12 +404,12 @@ router = APIRouter()
class PortfolioResponse(BaseModel):
id: int
stock_code: str
stock_name: str = None
stock_name: Optional[str] = None
quantity: int
average_price: Decimal = None
current_price: Decimal = None
profit_loss: Decimal = None
profit_loss_rate: Decimal = None
average_price: Optional[Decimal] = None
current_price: Optional[Decimal] = None
profit_loss: Optional[Decimal] = None
profit_loss_rate: Optional[Decimal] = None
market: str = "KRX"
strategy_type: str = "swing"

View File

@@ -0,0 +1,509 @@
"""사용자 스코프 포트폴리오 API (`_v2`, 멀티테넌트 1단계 §5.7).
기존 `portfolio.py`/`manual_buy.py`의 함수·시그니처는 전혀 건드리지 않는다.
이 파일은 `get_current_user`/`get_user_context`로 호출자를 해석해 사용자별
KIS 계좌(`ctx.kis`)·리스크 상태(`ctx.mdd`, `ctx.loss_tracker`)로 조회·매매한다.
- `MULTI_TENANT_ENABLED=False`(기본)이면 `CurrentUser.effective_user_id`가
legacy owner id 를 반환하고, `get_user_context`도 legacy 컨텍스트(기존 전역
싱글톤)를 반환하므로 기존 `/portfolio`와 동일하게 동작한다.
- Portfolio/TradeHistory/ActiveTrade 는 `user_id` 컬럼이 nullable로 이미 추가돼
있으나 `portfolio.stock_code`는 아직 전역 unique(0012 이전)이므로, 서로 다른
사용자가 동시에 같은 종목을 보유하면 DB unique 제약에 걸릴 수 있다(설계 문서
§3.2, §7 열린 질문 — 0012 마이그레이션 전까지는 알려진 제약).
- 레거시 호환 조회 규칙: `MULTI_TENANT_ENABLED=False`이면 `user_id == 대상 id`
또는 `user_id IS NULL`(백필 전 데이터)을 모두 포함해서 조회한다.
"""
import asyncio
import logging
import time
from datetime import datetime
from decimal import Decimal
from typing import Optional
import pytz
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import func, or_, select
from sqlalchemy.sql import false as sql_false
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.tenant_auth import CurrentUser, get_current_user
from app.models.models import Portfolio
from app.services.db_worker import (
enqueue_close_active_trade,
enqueue_close_active_trade_v2,
enqueue_portfolio_upsert,
enqueue_trade_history,
)
from app.services.tenant.context import UserContext
from app.services.tenant.registry import get_user_context, user_context_registry
from app.services.trade_executor import safe_place_order_v2
from app.services.utils import _is_domestic
from app.api.endpoints.manual_buy import BuyRequest
from app.api.endpoints.portfolio import PortfolioResponse, SellRequest
logger = logging.getLogger(__name__)
router = APIRouter()
_KST = pytz.timezone("Asia/Seoul")
def _kst_now() -> datetime:
return datetime.now(_KST).replace(tzinfo=None)
def _user_scope_filter(column, target_user_id: Optional[int]):
"""레거시 호환 필터: MULTI_TENANT_ENABLED=False 이면 NULL(백필 전) 행도 포함.
M1: `target_user_id=None`(관리자 미존재 등)일 때 `column == None`은 SQLAlchemy가
자동으로 `IS NULL`로 컴파일하므로, MULTI_TENANT_ENABLED=True 에서 그대로 두면
"본인 없음"이 아니라 "NULL 행 전체 노출"이 되어버린다. True 모드에서는 fail-closed로
빈 결과를 반환한다.
"""
if settings.MULTI_TENANT_ENABLED:
if target_user_id is None:
return sql_false()
return column == target_user_id
return or_(column == target_user_id, column.is_(None))
async def _resolve_target_user_id(
user: CurrentUser, user_id: Optional[int], db: AsyncSession
) -> int:
"""`?user_id=` 오버라이드(관리자 전용) 해석. 미지정 시 본인 effective_user_id."""
if user_id is None:
target = user.effective_user_id
if target is None:
raise HTTPException(status_code=400, detail="사용자 컨텍스트를 확인할 수 없습니다")
return target
if not user.is_admin:
raise HTTPException(status_code=403, detail="다른 사용자 조회는 관리자만 가능합니다")
return user_id
async def _resolve_ctx(
user: CurrentUser, user_id: Optional[int], db: AsyncSession
) -> UserContext:
"""`?user_id=` 오버라이드를 반영한 `UserContext` 해석 (KIS 잔고 조회용)."""
if user_id is None:
return await user_context_registry.resolve(user, db)
if not user.is_admin:
raise HTTPException(status_code=403, detail="다른 사용자 조회는 관리자만 가능합니다")
legacy_ctx = await user_context_registry.legacy(db)
if user_id == legacy_ctx.user_id:
return legacy_ctx
ctx = await user_context_registry.get(user_id, db)
if ctx is None:
raise HTTPException(status_code=404, detail=f"user_id={user_id} 의 KIS 계좌가 등록·검증되지 않았습니다")
return ctx
# ── GET /portfolio/v2 ─────────────────────────────────────────────────────
@router.get("", response_model=list[PortfolioResponse])
async def get_portfolio_v2(
user_id: Optional[int] = None,
user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""사용자 스코프 보유 종목 조회. `?user_id=`는 관리자 전용 타 사용자 열람."""
target_user_id = await _resolve_target_user_id(user, user_id, db)
query = select(Portfolio).where(
Portfolio.quantity > 0, _user_scope_filter(Portfolio.user_id, target_user_id)
)
result = await db.execute(query)
return list(result.scalars().all())
# ── GET /portfolio/v2/summary ─────────────────────────────────────────────
@router.get("/summary")
async def get_portfolio_summary_v2(
user_id: Optional[int] = None,
user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""사용자 스코프 포트폴리오 요약. 총자산/예수금은 `ctx.kis.get_balance()` 기준."""
target_user_id = await _resolve_target_user_id(user, user_id, db)
ctx = await _resolve_ctx(user, user_id, db)
query = select(Portfolio).where(
Portfolio.quantity > 0, _user_scope_filter(Portfolio.user_id, target_user_id)
)
holdings = list((await db.execute(query)).scalars().all())
total_value = sum(float(h.current_price or 0) * h.quantity for h in holdings)
total_investment = sum(float(h.average_price or 0) * h.quantity for h in holdings)
total_profit_loss = total_value - total_investment
total_profit_loss_rate = (total_profit_loss / total_investment * 100) if total_investment else 0.0
available_cash = 0.0
total_assets = 0.0
try:
balance = await ctx.kis.get_balance()
output2 = balance.get("output2") or [{}]
b = output2[0] if isinstance(output2, list) else output2
available_cash = float(b.get("dnca_tot_amt") or 0)
total_assets = float(b.get("tot_evlu_amt") or 0)
except Exception as e:
logger.warning(f"[portfolio_v2] user_id={target_user_id} 잔고 조회 실패: {e}")
return {
"user_id": target_user_id,
"total_stocks": len(holdings),
"total_value": total_value,
"total_investment": total_investment,
"total_profit_loss": total_profit_loss,
"total_profit_loss_rate": total_profit_loss_rate,
"available_cash": available_cash,
"total_assets": total_assets,
}
# ── POST /portfolio/v2/sync ───────────────────────────────────────────────
_sync_locks: dict[int, asyncio.Lock] = {}
def _get_sync_lock(user_id: int) -> asyncio.Lock:
lock = _sync_locks.get(user_id)
if lock is None:
lock = asyncio.Lock()
_sync_locks[user_id] = lock
return lock
@router.post("/sync")
async def sync_portfolio_v2(
ctx: UserContext = Depends(get_user_context),
db: AsyncSession = Depends(get_db),
):
"""`ctx.kis.get_balance()` 로 KIS 잔고를 조회해 사용자 스코프 Portfolio를 upsert/삭제.
`sync_portfolio`(portfolio.py)와 동일하게 단일 트랜잭션 직접 DB 접근이 허용되는
예외 경로다(CLAUDE.md §3.2). 다른 사용자 행은 절대 건드리지 않도록 모든 쿼리에
`Portfolio.user_id == ctx.user_id` 조건을 명시한다. legacy 컨텍스트(멀티테넌트
비활성 또는 API 키 호출)는 `user_id`가 legacy owner id 또는 None이며, 이 경우
기존 `/portfolio/sync`가 이미 전체 동기화를 수행하므로 여기서는 스킵한다.
"""
if ctx.is_legacy:
return {"message": "legacy 컨텍스트는 기존 /portfolio/sync 를 사용하세요", "skipped": True}
if ctx.user_id is None:
raise HTTPException(status_code=400, detail="사용자 컨텍스트를 확인할 수 없습니다")
lock = _get_sync_lock(ctx.user_id)
try:
await asyncio.wait_for(lock.acquire(), timeout=30)
except asyncio.TimeoutError:
raise HTTPException(status_code=503, detail="Portfolio sync timeout (30s)")
try:
balance_data = await ctx.kis.get_balance()
if not balance_data or balance_data.get("rt_cd") != "0":
error_msg = balance_data.get("msg1", "Unknown error") if balance_data else "No response"
raise HTTPException(status_code=502, detail=f"Balance API error: {error_msg}")
result = await db.execute(select(Portfolio).where(Portfolio.user_id == ctx.user_id))
existing = {p.stock_code: p for p in result.scalars().all()}
processed_codes: set[str] = set()
for item in balance_data.get("output1") or []:
stock_code = item.get("pdno")
quantity = int(item.get("hldg_qty", 0) or 0)
if not stock_code or quantity <= 0:
continue
processed_codes.add(stock_code)
average_price = Decimal(str(item.get("pchs_avg_pric", 0)))
current_price = Decimal(str(item.get("prpr", 0)))
profit_loss = Decimal(str(item.get("evlu_pfls_amt", 0)))
profit_loss_rate = Decimal(str(item.get("evlu_pfls_rt", 0)))
if stock_code in existing:
h = existing[stock_code]
h.stock_name = item.get("prdt_name")
h.quantity = quantity
h.average_price = average_price
h.current_price = current_price
h.profit_loss = profit_loss
h.profit_loss_rate = profit_loss_rate
h.market = "KRX"
else:
db.add(Portfolio(
user_id=ctx.user_id,
stock_code=stock_code,
stock_name=item.get("prdt_name"),
quantity=quantity,
average_price=average_price,
current_price=current_price,
profit_loss=profit_loss,
profit_loss_rate=profit_loss_rate,
market="KRX",
))
for code, h in existing.items():
if code not in processed_codes:
await db.delete(h)
await db.commit()
if ctx.portfolio_cache is not None:
try:
ctx.portfolio_cache.mark_changed()
except Exception:
pass
return {"message": "Portfolio synchronized", "count": len(processed_codes), "user_id": ctx.user_id}
except HTTPException:
raise
except Exception as e:
logger.error(f"[portfolio_v2] sync 실패 user_id={ctx.user_id}: {e}", exc_info=True)
raise HTTPException(status_code=502, detail=f"Failed to sync portfolio: {e}")
finally:
lock.release()
# ── POST /portfolio/v2/buy ─────────────────────────────────────────────────
_inflight_v2: set[tuple] = set()
_last_done_v2: dict[tuple, float] = {}
_DUP_COOLDOWN_SEC = 120.0
@router.post("/buy")
async def buy_stock_v2(
request: BuyRequest,
ctx: UserContext = Depends(get_user_context),
db: AsyncSession = Depends(get_db),
):
"""사용자 스코프 수동 매수 (`manual_buy._buy_stock_impl`의 `_v2` 버전)."""
stock_code = request.stock_code.strip()
if not _is_domestic(stock_code):
raise HTTPException(status_code=400, detail="국내 종목(6자리 코드)만 수동 매수를 지원합니다")
if settings.TRADING_HALTED:
raise HTTPException(status_code=423, detail="TRADING_HALTED — 전체 매수 중단 상태")
if ctx.user_id is None and not ctx.is_legacy:
raise HTTPException(status_code=400, detail="사용자 컨텍스트를 확인할 수 없습니다")
# L4: legacy 컨텍스트는 registry.legacy()가 조립 시점에 스냅샷한 필드값(최대 60초 TTL 지연)
# 대신 `effective_auto_trade_enabled`로 settings.AUTO_TRADE_ENABLED를 즉시 재조회한다.
if not ctx.is_legacy and not ctx.effective_auto_trade_enabled:
raise HTTPException(status_code=423, detail="사용자 자동매매가 비활성화 상태입니다 (내 계좌 설정에서 활성화)")
dedup_key = (ctx.user_id, stock_code)
if dedup_key in _inflight_v2:
raise HTTPException(status_code=409, detail="같은 종목 매수 주문이 아직 처리 중입니다")
since = time.monotonic() - _last_done_v2.get(dedup_key, -1e9)
if since < _DUP_COOLDOWN_SEC:
raise HTTPException(
status_code=409,
detail=f"{int(_DUP_COOLDOWN_SEC - since)}초 전 같은 종목을 매수했습니다. 중복 주문 방지 쿨다운 중",
)
_inflight_v2.add(dedup_key)
try:
return await _buy_stock_impl_v2(request, stock_code, db, ctx)
finally:
_inflight_v2.discard(dedup_key)
async def _buy_stock_impl_v2(request: BuyRequest, stock_code: str, db: AsyncSession, ctx: UserContext):
try:
price_data = await ctx.kis.get_current_price(stock_code)
output = (price_data or {}).get("output") or {}
current_price = float(output.get("stck_prpr") or 0)
stock_name = request.stock_name or output.get("hts_kor_isnm") or stock_code
except Exception as e:
raise HTTPException(status_code=502, detail=f"현재가 조회 실패: {e}")
if current_price <= 0:
raise HTTPException(status_code=404, detail=f"현재가를 가져올 수 없습니다: {stock_code}")
# ── 리스크 가드 (CLAUDE.md §5, 사용자 스코프) ─────────────────────────
if current_price <= settings.MIN_PRICE_KRW:
raise HTTPException(status_code=400, detail=f"가격 필터: {settings.MIN_PRICE_KRW:,}원 이하 종목 매수 금지")
if await ctx.loss_tracker.is_banned_safe(stock_code):
raise HTTPException(status_code=400, detail="연속 손실 진입 금지 종목입니다 (loss_tracker ban)")
if not await ctx.mdd.can_open_new_position():
raise HTTPException(status_code=400, detail="MDD 신규 진입 중단 상태입니다")
held = await db.execute(
select(Portfolio).where(
Portfolio.stock_code == stock_code, _user_scope_filter(Portfolio.user_id, ctx.user_id)
)
)
holding = held.scalar_one_or_none()
if holding is None:
cnt = (await db.execute(
select(func.count()).select_from(Portfolio).where(
Portfolio.quantity > 0, _user_scope_filter(Portfolio.user_id, ctx.user_id)
)
)).scalar() or 0
if cnt >= settings.MAX_HOLDINGS_COUNT:
raise HTTPException(status_code=400, detail=f"최대 보유 종목 수({settings.MAX_HOLDINGS_COUNT}) 초과")
quantity = request.quantity
order_price = float(request.price) if request.price else current_price
if quantity is None:
try:
balance = await ctx.kis.get_balance()
output2 = balance.get("output2") or [{}]
b = output2[0] if isinstance(output2, list) else output2
available_cash = float(b.get("dnca_tot_amt") or 0)
total_assets = float(b.get("tot_evlu_amt") or 0)
except Exception as e:
raise HTTPException(status_code=502, detail=f"잔고 조회 실패: {e}")
budget = min(total_assets * settings.MAX_POSITION_SIZE, available_cash)
quantity = int(budget // order_price)
if quantity < 1:
raise HTTPException(
status_code=400,
detail=f"예산 부족: 예산 {budget:,.0f}원 < 1주 {order_price:,.0f}원 (예수금 {available_cash:,.0f}원)",
)
logger.info(f"🟢 [v2] user_id={ctx.user_id} 수동 매수 요청: {stock_name}({stock_code}) {quantity}주")
order_result = await safe_place_order_v2(
ctx, stock_code=stock_code, order_type="BUY", quantity=quantity,
price=request.price, is_domestic=True,
)
if not order_result or order_result.get("rt_cd") != "0":
msg = order_result.get("msg1", "알 수 없는 오류") if order_result else "API 호출 실패"
raise HTTPException(status_code=500, detail=f"매수 주문 실패: {msg}")
order_id = (order_result.get("output") or {}).get("ODNO")
now = _kst_now()
trade_history_payload = {
"stock_code": stock_code,
"stock_name": stock_name,
"order_type": "BUY",
"order_price": float(request.price) if request.price else None,
"order_quantity": quantity,
"executed_price": float(order_price),
"executed_quantity": quantity,
"total_amount": float(order_price * quantity),
"status": "COMPLETED",
"order_id": order_id,
"message": "수동 매수 (v2, 사용자 스코프)",
"strategy_type": "manual",
"created_at": now,
"executed_at": now,
}
# M3: legacy 컨텍스트는 user_id를 payload에 넣지 않는다 — 스윙/데이트레이딩 등
# 기존 레거시 쓰기 경로가 전부 user_id 없이 쓰므로(NULL), 정합을 맞춰야
# `_user_scope_filter`(NULL 매칭)로 같은 행이 조회된다.
if not ctx.is_legacy:
trade_history_payload["user_id"] = ctx.user_id
await enqueue_trade_history(trade_history_payload)
if holding is None:
portfolio_payload = {
"stock_code": stock_code,
"stock_name": stock_name,
"quantity": quantity,
"average_price": order_price,
"current_price": current_price,
"profit_loss": 0,
"profit_loss_rate": 0,
"market": "KRX",
"strategy_type": "manual",
"currency_code": "KRW",
"exchange_code": "KRX",
}
if not ctx.is_legacy:
portfolio_payload["user_id"] = ctx.user_id
await enqueue_portfolio_upsert(portfolio_payload)
_last_done_v2[(ctx.user_id, stock_code)] = time.monotonic()
logger.info(f"✅ [v2] user_id={ctx.user_id} 수동 매수 주문 완료: {stock_name} {quantity}주 @ {order_price:,.0f}원")
return {
"success": True,
"message": f"{stock_name} {quantity}주 매수 주문 완료",
"order_id": order_id,
"quantity": quantity,
"price": order_price,
}
# ── POST /portfolio/v2/sell ───────────────────────────────────────────────
@router.post("/sell")
async def sell_stock_v2(
request: SellRequest,
ctx: UserContext = Depends(get_user_context),
db: AsyncSession = Depends(get_db),
):
"""사용자 스코프 수동 매도. 매도 대상은 본인 보유 종목만 조회(타 사용자 종목은 404)."""
if ctx.user_id is None and not ctx.is_legacy:
raise HTTPException(status_code=400, detail="사용자 컨텍스트를 확인할 수 없습니다")
stock_code = request.stock_code
quantity = request.quantity
price = request.price
result = await db.execute(
select(Portfolio).where(
Portfolio.stock_code == stock_code, _user_scope_filter(Portfolio.user_id, ctx.user_id)
)
)
holding = result.scalar_one_or_none()
if not holding:
raise HTTPException(status_code=404, detail=f"보유하지 않은 종목입니다: {stock_code}")
if holding.quantity < quantity:
raise HTTPException(
status_code=400,
detail=f"보유 수량 부족: {holding.quantity}주 보유 중, {quantity}주 매도 요청",
)
stock_name = holding.stock_name
original_holding_qty = holding.quantity
average_price = holding.average_price
order_result = await safe_place_order_v2(
ctx, stock_code=stock_code, order_type="SELL", quantity=quantity, price=price, is_domestic=True,
)
if not order_result or order_result.get("rt_cd") != "0":
error_msg = order_result.get("msg1", "알 수 없는 오류") if order_result else "API 호출 실패"
raise HTTPException(status_code=500, detail=f"매도 주문 실패: {error_msg}")
executed_price = price if price else float(holding.current_price or 0)
profit_loss = (executed_price - float(average_price or 0)) * quantity
profit_loss_rate = ((executed_price / float(average_price)) - 1) * 100 if average_price else 0.0
sell_history_payload = {
"stock_code": stock_code,
"stock_name": stock_name,
"order_type": "SELL",
"order_price": float(price) if price else None,
"order_quantity": quantity,
"executed_price": float(executed_price),
"executed_quantity": quantity,
"total_amount": float(executed_price * quantity),
"status": "COMPLETED",
"order_id": (order_result.get("output") or {}).get("ODNO"),
"message": f"수동 청산 (v2): 손익 {profit_loss:+,.0f}원 ({profit_loss_rate:+.2f}%)",
"exit_reason": f"사용자 수동 청산 (손익: {profit_loss:+,.0f}원, 손익률: {profit_loss_rate:+.2f}%)",
"net_profit_loss": profit_loss,
"profit_loss_rate": profit_loss_rate,
"strategy_type": "manual",
"created_at": _kst_now(),
"executed_at": _kst_now(),
}
if not ctx.is_legacy:
sell_history_payload["user_id"] = ctx.user_id
await enqueue_trade_history(sell_history_payload)
if quantity >= original_holding_qty:
await db.delete(holding)
if ctx.is_legacy:
# M3: legacy 스케줄러/기존 `/portfolio/sell`과 동일하게 user_id 없는 close 사용
# (ActiveTrade.user_id == owner 필터로 NULL 행을 못 찾아 유령 포지션으로
# 남는 결함 방지).
await enqueue_close_active_trade(stock_code, strategy_name=holding.strategy_type)
else:
await enqueue_close_active_trade_v2(ctx.user_id, stock_code, strategy_name=holding.strategy_type)
else:
holding.quantity -= quantity
holding.current_price = Decimal(str(executed_price))
await db.commit()
logger.info(f"✅ [v2] user_id={ctx.user_id} 수동 매도 완료: {stock_name} {quantity}주 @ {executed_price:,.0f}원")
return {
"success": True,
"message": f"{stock_name} {quantity}주 매도 주문 완료",
"order_id": (order_result.get("output") or {}).get("ODNO"),
"profit_loss": float(profit_loss),
"profit_loss_rate": float(profit_loss_rate),
}

View File

@@ -0,0 +1,125 @@
"""사용자 스코프 거래 이력 조회 API (`_v2`, 멀티테넌트 1단계 §5.7).
기존 `trades.py`의 `GET /trades`(파라미터·손익 계산 로직)는 그대로 두고,
`TradeHistory.user_id` 필터만 추가한 신규 라우터. 레거시 호환 규칙은
`portfolio_v2.py`의 `_user_scope_filter`와 동일(`MULTI_TENANT_ENABLED=False`면
`user_id == 대상 id` 또는 `user_id IS NULL` 모두 포함).
"""
import logging
import re
from datetime import datetime, timedelta
from decimal import Decimal
from typing import List, Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.tenant_auth import CurrentUser, get_current_user
from app.models.models import TradeHistory
from app.api.endpoints.portfolio_v2 import _resolve_target_user_id, _user_scope_filter
from app.api.endpoints.trades import TradeHistoryResponse
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("", response_model=List[TradeHistoryResponse])
async def get_trade_history_v2(
skip: int = 0,
limit: int = 100,
stock_code: Optional[str] = None,
status: Optional[str] = None,
order_type: Optional[str] = None,
days: int = 30,
user_id: Optional[int] = None,
user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""사용자 스코프 거래 이력 조회 (`GET /trades`와 동일 파라미터 + 손익 파싱)."""
target_user_id = await _resolve_target_user_id(user, user_id, db)
query = select(TradeHistory).where(_user_scope_filter(TradeHistory.user_id, target_user_id))
since_date = datetime.utcnow() - timedelta(days=days)
query = query.where(TradeHistory.created_at >= since_date)
if stock_code:
query = query.where(TradeHistory.stock_code == stock_code)
if status:
query = query.where(TradeHistory.status == status)
if order_type:
query = query.where(TradeHistory.order_type == order_type)
query = query.order_by(TradeHistory.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
trades = result.scalars().all()
trade_responses = []
for trade in trades:
response = TradeHistoryResponse.model_validate(trade)
if trade.order_type == "SELL" and trade.status in ("EXECUTED", "COMPLETED"):
if hasattr(trade, "net_profit_loss") and trade.net_profit_loss is not None:
response.profit_loss = trade.net_profit_loss
if hasattr(trade, "profit_loss_rate") and trade.profit_loss_rate is not None:
response.profit_loss_rate = float(trade.profit_loss_rate)
if response.profit_loss is None or response.profit_loss_rate is None:
text_to_parse = trade.exit_reason or trade.message or ""
if response.profit_loss is None:
profit_match = re.search(r"손익:\s*([+-]?[\d,]+)원", text_to_parse)
if profit_match:
try:
response.profit_loss = Decimal(profit_match.group(1).replace(",", ""))
except Exception as e:
logger.debug(f"[trades_v2] 손익 금액 파싱 실패: {e}")
if response.profit_loss_rate is None:
message_rate_match = re.search(r"손익률\s*([+-]?[\d.]+)%", trade.message or "")
rate_match = message_rate_match or re.search(r"([+-]?[\d.]+)%", text_to_parse)
if rate_match:
try:
response.profit_loss_rate = float(rate_match.group(1))
except Exception as e:
logger.debug(f"[trades_v2] 손익률 파싱 실패: {e}")
trade_responses.append(response)
return trade_responses
# ── DELETE /trades/v2/pending ────────────────────────────────────────────
@router.delete("/pending")
async def delete_pending_trades_v2(
user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""사용자 스코프 PENDING/FAILED 매수 시도 정리 (M6).
기존 `DELETE /trades/pending`(trades.py)은 `user_id` 조건 없이 전체 삭제라
`MULTI_TENANT_ENABLED=True`에서 일반 사용자가 타 사용자의 이력을 지울 수 있었다.
`_user_scope_filter`와 동일 규칙(MT=False면 본인 id 또는 NULL 행 포함, MT=True면
본인 행만)을 적용해 legacy 컨텍스트(관리자/API 키, MT=False)는 기존 동작과 동일하고,
일반 사용자는 본인 이력만 지운다.
"""
target_user_id = await _resolve_target_user_id(user, None, db)
query = select(TradeHistory).where(
TradeHistory.status.in_(["PENDING", "FAILED"]),
_user_scope_filter(TradeHistory.user_id, target_user_id),
)
result = await db.execute(query)
pending_trades = result.scalars().all()
count = len(pending_trades)
for trade in pending_trades:
await db.delete(trade)
await db.commit()
logger.info(f"✅ [v2] user_id={target_user_id} 정리 완료: {count}개의 PENDING/FAILED 매수 시도 삭제")
return {"deleted_count": count, "message": f"Cleaned up {count} pending/failed trades"}