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,65 @@
"""add user_kis_credentials table (multi-tenant phase 1)
Revision ID: 0009
Revises: 0008
Create Date: 2026-09-21
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "0009"
down_revision = "0008"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
exists = conn.execute(sa.text(
"SELECT 1 FROM information_schema.tables WHERE table_name='user_kis_credentials'"
)).fetchone()
if exists:
return # create_all 로 이미 생성됨
op.create_table(
"user_kis_credentials",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("mode", sa.String(10), nullable=False),
sa.Column("app_key_enc", sa.Text(), nullable=False),
sa.Column("app_secret_enc", sa.Text(), nullable=False),
sa.Column("account_no_enc", sa.Text(), nullable=False),
sa.Column("account_product_code", sa.String(2), nullable=False, server_default="01"),
sa.Column("app_key_hash", sa.String(64), nullable=False),
sa.Column("app_key_hint", sa.String(8), nullable=True),
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("auto_trade_enabled", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("last_verified_at", sa.DateTime(), nullable=True),
sa.Column("key_version", sa.Integer(), nullable=False, server_default="1"),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=True),
)
op.create_index(
"ix_user_kis_credentials_user_id", "user_kis_credentials", ["user_id"], unique=True
)
op.create_index(
"ix_user_kis_credentials_app_key_hash", "user_kis_credentials", ["app_key_hash"], unique=True
)
def downgrade() -> None:
# L3: 테이블이 `create_all`(SQLAlchemy 모델의 `unique=True` 컬럼)로 먼저 생성된
# 경우 실제로는 named index가 아니라 unique constraint(`..._key`)로 존재해
# `drop_index`가 실패한다. 존재할 때만 드롭한다.
conn = op.get_bind()
for index_name in (
"ix_user_kis_credentials_app_key_hash",
"ix_user_kis_credentials_user_id",
):
exists = conn.execute(sa.text(
"SELECT 1 FROM pg_indexes WHERE tablename='user_kis_credentials' AND indexname=:name"
), {"name": index_name}).fetchone()
if exists:
op.drop_index(index_name, table_name="user_kis_credentials")
op.drop_table("user_kis_credentials")

View File

@@ -0,0 +1,79 @@
"""add nullable user_id to tenant-scoped tables + stock_bans_v2 (multi-tenant phase 1)
Revision ID: 0010
Revises: 0009
Create Date: 2026-09-21
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "0010"
down_revision = "0009"
branch_labels = None
depends_on = None
# (table, index_name)
_USER_ID_TABLES = [
"portfolio",
"trade_history",
"active_trades",
"trade_journals",
"trading_sessions",
"mcp_tool_calls",
"claude_analysis_log",
]
def _has_column(conn, table: str, column: str) -> bool:
return conn.execute(sa.text(
"SELECT 1 FROM information_schema.columns WHERE table_name=:t AND column_name=:c"
), {"t": table, "c": column}).fetchone() is not None
def _has_table(conn, table: str) -> bool:
return conn.execute(sa.text(
"SELECT 1 FROM information_schema.tables WHERE table_name=:t"
), {"t": table}).fetchone() is not None
def upgrade() -> None:
conn = op.get_bind()
for table in _USER_ID_TABLES:
if not _has_table(conn, table):
continue # create_all 이전 스키마 없음 — 스킵(다음 실행에서 재시도)
if _has_column(conn, table, "user_id"):
continue # create_all 로 이미 생성됨
op.add_column(
table,
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
)
op.create_index(f"ix_{table}_user_id", table, ["user_id"])
if not _has_table(conn, "stock_bans_v2"):
op.create_table(
"stock_bans_v2",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("stock_code", sa.String(20), nullable=False),
sa.Column("consecutive_losses", sa.Integer(), nullable=False, server_default="0"),
sa.Column("ban_until", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_stock_bans_v2_user_id", "stock_bans_v2", ["user_id"])
op.create_index("ix_stock_bans_v2_stock_code", "stock_bans_v2", ["stock_code"])
op.create_unique_constraint(
"uq_stock_bans_v2_user_stock", "stock_bans_v2", ["user_id", "stock_code"]
)
def downgrade() -> None:
conn = op.get_bind()
if _has_table(conn, "stock_bans_v2"):
op.drop_table("stock_bans_v2")
for table in _USER_ID_TABLES:
if _has_table(conn, table) and _has_column(conn, table, "user_id"):
op.drop_index(f"ix_{table}_user_id", table_name=table)
op.drop_column(table, "user_id")

View File

@@ -0,0 +1,73 @@
"""backfill legacy owner user_id + stock_bans -> stock_bans_v2 (multi-tenant phase 1)
Revision ID: 0011
Revises: 0010
Create Date: 2026-09-21
- legacy_owner = users 중 is_admin=True, id 최소인 사용자.
- 없으면 스킵 (앱 기동 시 ensure_bootstrap_admin() 또는 관리자 API
POST /auth/admin/tenant/migrate-legacy 가 동일 로직으로 재실행 가능 — idempotent).
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "0011"
down_revision = "0010"
branch_labels = None
depends_on = None
_BACKFILL_TABLES = [
"portfolio",
"trade_history",
"active_trades",
"trade_journals",
"trading_sessions",
"mcp_tool_calls",
"claude_analysis_log",
]
def _has_table(conn, table: str) -> bool:
return conn.execute(sa.text(
"SELECT 1 FROM information_schema.tables WHERE table_name=:t"
), {"t": table}).fetchone() is not None
def upgrade() -> None:
conn = op.get_bind()
if not _has_table(conn, "users"):
return
owner_row = conn.execute(sa.text(
"SELECT id FROM users WHERE is_admin = TRUE ORDER BY id LIMIT 1"
)).fetchone()
if owner_row is None:
return # 관리자 미존재 — migrate-legacy API 또는 ensure_bootstrap_admin() 이 지연 백필
owner_id = owner_row[0]
for table in _BACKFILL_TABLES:
if not _has_table(conn, table):
continue
conn.execute(sa.text(
f"UPDATE {table} SET user_id = :owner WHERE user_id IS NULL"
), {"owner": owner_id})
if _has_table(conn, "stock_bans") and _has_table(conn, "stock_bans_v2"):
conn.execute(sa.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})
def downgrade() -> None:
# 데이터 백필 롤백은 위험(다른 정상 데이터와 구분 불가) — no-op.
# 필요 시 수동으로 UPDATE ... SET user_id = NULL WHERE user_id = <owner_id> 실행.
pass

View File

@@ -8,13 +8,14 @@ API 라우터 통합 모듈
"""
from fastapi import APIRouter, Depends
from app.core.security import require_api_key, optional_api_key
from app.core.tenant_auth import deny_non_owner_session_when_mt
from app.api.endpoints import (
trading_rules, trades, portfolio, market_data, mcp, recommendations,
trading_rules, trades, trades_v2, portfolio, portfolio_v2, market_data, mcp, recommendations,
monitoring, tensorflow_predictions, debug, risk_management, ml_ensemble,
journals, day_trading, strategy_config, backtest, analysis,
swing_portfolio, daytrading_v2, us_trading, kr_backtest,
market_intelligence, market_playbook, news,
adaptive_strategy, manual_buy, auth,
adaptive_strategy, manual_buy, auth, kis_credentials,
)
api_router = APIRouter()
@@ -23,16 +24,31 @@ api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
# ── 매매 실행 / 설정 변경 API (require_api_key) ───────────────────────────
# portfolio_v2/trades_v2 는 반드시 기존 portfolio/trades 라우터보다 먼저 등록한다
# (라우트는 등록 순서대로 매칭되므로, 이렇게 해야 "/portfolio/v2..."·"/trades/v2..."가
# 기존 동적 경로(예: /trades/{trade_id})에 먼저 잡히지 않는다).
api_router.include_router(
portfolio_v2.router, prefix="/portfolio/v2", tags=["portfolio_v2"],
dependencies=[Depends(require_api_key)],
)
api_router.include_router(
trades_v2.router, prefix="/trades/v2", tags=["trades_v2"],
dependencies=[Depends(require_api_key)],
)
api_router.include_router(
trading_rules.router, prefix="/rules", tags=["trading_rules"],
dependencies=[Depends(require_api_key)],
)
api_router.include_router(
portfolio.router, prefix="/portfolio", tags=["portfolio"],
dependencies=[Depends(require_api_key)],
dependencies=[Depends(require_api_key), Depends(deny_non_owner_session_when_mt)],
)
api_router.include_router(
manual_buy.router, prefix="/portfolio", tags=["portfolio"],
dependencies=[Depends(require_api_key), Depends(deny_non_owner_session_when_mt)],
)
api_router.include_router(
kis_credentials.router, prefix="/account", tags=["account"],
dependencies=[Depends(require_api_key)],
)
api_router.include_router(
@@ -81,7 +97,7 @@ api_router.include_router(
# ── 내부/인프라 API ───────────────────────────────────────────────────────
api_router.include_router(
trades.router, prefix="/trades", tags=["trades"],
dependencies=[Depends(require_api_key)],
dependencies=[Depends(require_api_key), Depends(deny_non_owner_session_when_mt)],
)
api_router.include_router(
mcp.router, prefix="/mcp", tags=["mcp"],

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

View File

@@ -32,6 +32,13 @@ class Settings(BaseSettings):
BACKEND_HOST: str = "0.0.0.0"
BACKEND_PORT: int = 8000
# ── 멀티테넌트(사용자별 매매) 1단계 ─────────────────────────────────
MULTI_TENANT_ENABLED: bool = False # True 전환 시 사용자별 KIS 계좌·리스크 상태 분리 (기본 False = 기존 단일 계좌 동작)
CREDENTIALS_ENCRYPTION_KEY: str = "" # user_kis_credentials 암복호화 키 파생 소스. 비어있으면 SECRET_KEY 폴백
CREDENTIALS_ENCRYPTION_KEY_PREVIOUS: str = "" # 키 로테이션 중 구 키(복호화만 허용)
BOOTSTRAP_ADMIN_USERNAME: str = "admin" # 앱 최초 기동 시 users 테이블이 비어있으면 시드할 관리자 계정명
BOOTSTRAP_ADMIN_PASSWORD: str = "" # 비어있으면 부트스트랩 관리자 시드 건너뜀
# ═════════════════════════════════════════════════════════════════════════════
# 데이터베이스 & 메시지 큐
# ═════════════════════════════════════════════════════════════════════════════

View File

@@ -0,0 +1,95 @@
"""사용자 KIS 자격증명 암복호화 (멀티테넌트 1단계).
- 키 파생: PBKDF2-HMAC-SHA256(200_000 회) → Fernet 키.
소스는 settings.CREDENTIALS_ENCRYPTION_KEY, 비어있으면 settings.SECRET_KEY 폴백.
- 로테이션: CREDENTIALS_ENCRYPTION_KEY_PREVIOUS 가 설정되면 MultiFernet 으로
구 키 복호화(읽기)만 허용, 암호화(쓰기)는 항상 현재 키(version 1)를 사용.
버전 2단계 확장 시 key_version 값을 늘리고 _derive_key(version=N) 로 파생하면 됨.
사용:
from app.core.crypto import encrypt, decrypt, current_key_version, is_encryption_configured
token = encrypt("plain-app-key")
plain = decrypt(token, key_version=1)
"""
import base64
import functools
import hashlib
import logging
from cryptography.fernet import Fernet, InvalidToken, MultiFernet
from app.core.config import settings
logger = logging.getLogger(__name__)
_KDF_ITERATIONS = 200_000
_DEFAULT_SECRET = "change-this-secret-key"
class CredentialDecryptError(ValueError):
"""자격증명 복호화 실패 — 키 로테이션/설정 변경 등 서버 측 원인 진단용 (L2).
기존 호출부가 `ValueError`를 기대할 수 있으므로 하위 호환을 위해 `ValueError`를 상속한다.
"""
@functools.lru_cache(maxsize=8)
def _derive_key(secret: str, version: int) -> bytes:
"""PBKDF2 로 Fernet 대칭키(urlsafe base64, 32바이트) 파생.
PBKDF2 200_000회 반복은 호출당 ~0.1~0.2초 CPU 연산이라(M4) (secret, version) 조합별로
캐시한다 — secret 후보는 CREDENTIALS_ENCRYPTION_KEY/SECRET_KEY(+PREVIOUS) 뿐이라
프로세스 수명 동안 조합 수가 매우 작다.
"""
salt = f"autostock-kis-cred-v{version}".encode()
raw = hashlib.pbkdf2_hmac("sha256", secret.encode(), salt, _KDF_ITERATIONS)
return base64.urlsafe_b64encode(raw)
def _key_source() -> str:
return settings.CREDENTIALS_ENCRYPTION_KEY or settings.SECRET_KEY
def is_encryption_configured() -> bool:
"""SECRET_KEY 가 기본값이거나 너무 짧으면(길이<32) 암호화 저장을 거부."""
secret = _key_source()
if secret == _DEFAULT_SECRET:
return False
if len(secret) < 32:
return False
return True
def current_key_version() -> int:
"""현재 암호화에 사용하는 키 버전. 1단계는 항상 1(로테이션은 §9 2단계에서 증가)."""
return 1
def _fernet_for_write() -> Fernet:
return Fernet(_derive_key(_key_source(), current_key_version()))
def _fernet_for_read(key_version: int) -> MultiFernet:
"""복호화용 — 현재 키 + (있으면) 이전 키 순으로 시도."""
keys = [Fernet(_derive_key(_key_source(), key_version))]
if settings.CREDENTIALS_ENCRYPTION_KEY_PREVIOUS:
keys.append(Fernet(_derive_key(settings.CREDENTIALS_ENCRYPTION_KEY_PREVIOUS, key_version)))
return MultiFernet(keys)
def encrypt(plain: str) -> str:
"""평문을 암호화해 저장용 문자열(토큰)로 반환. 항상 current_key_version() 기준."""
if not is_encryption_configured():
raise RuntimeError(
"CREDENTIALS_ENCRYPTION_KEY/SECRET_KEY 미설정 또는 기본값 — 자격증명 암호화 저장 불가"
)
return _fernet_for_write().encrypt(plain.encode()).decode()
def decrypt(token: str, key_version: int = 1) -> str:
"""저장된 토큰을 평문으로 복호화. 실패 시 `CredentialDecryptError`(L2, `ValueError` 서브클래스)."""
try:
return _fernet_for_read(key_version).decrypt(token.encode()).decode()
except InvalidToken as e:
logger.error("[Crypto] 자격증명 복호화 실패 (key_version=%s)", key_version)
raise CredentialDecryptError("자격증명 복호화 실패 — 암호화 키 불일치") from e

View File

@@ -0,0 +1,27 @@
"""tz-aware/naive datetime 경계 정규화 헬퍼.
stock_bans / stock_bans_v2 등 일부 테이블의 DateTime 컬럼은 tz-naive인 반면,
`loss_tracker.record_trade_result()`가 만드는 ban_until은 tz-aware(UTC)이다.
asyncpg는 offset-naive/aware 혼용 시 DataError(저장)·TypeError(비교)를 던지므로,
DB 경계(저장 직전/로드 직후)에서 반드시 이 헬퍼로 정규화한다.
"""
from datetime import datetime, timezone
from typing import Optional
def to_naive_utc(dt: Optional[datetime]) -> Optional[datetime]:
"""DB 저장 직전 정규화: tz-aware → UTC 변환 후 tzinfo 제거. naive는 그대로 반환."""
if dt is None:
return None
if dt.tzinfo is not None:
return dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt
def to_aware_utc(dt: Optional[datetime]) -> Optional[datetime]:
"""DB에서 읽은 직후 정규화: tz-naive → UTC로 간주해 tzinfo 부착. aware는 그대로 반환."""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)

View File

@@ -0,0 +1,134 @@
"""멀티테넌트 사용자 컨텍스트 의존성 (§5.4).
기존 `require_api_key`(security.py)는 라우터 등록에 그대로 유지하고, 이 모듈은
그 위에서 "누구인지"를 해석하는 추가 레이어다. 엔드포인트 함수 시그니처에
`user: CurrentUser = Depends(get_current_user)` 파라미터를 새로 추가하는 것은
기존 함수 시그니처 변경에 해당하므로, 사용자 스코프가 필요한 엔드포인트는
`_v2` 신규 함수/신규 라우터에서만 이 의존성을 사용할 것.
MULTI_TENANT_ENABLED=False 인 동안에도 세션 사용자의 `effective_user_id`는
legacy owner id 를 반환해 하위 호환을 유지한다.
"""
import hmac
import logging
from dataclasses import dataclass
from fastapi import Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import _get_api_key, require_api_key
from app.models.models import User
logger = logging.getLogger(__name__)
@dataclass
class CurrentUser:
"""인증된 호출 주체. API 키 호출자는 legacy owner 로 매핑된다."""
id: int | None # None = legacy owner 도 없음(관리자 미존재 상태)
username: str
is_admin: bool
is_api_key: bool
_legacy_owner_id: int | None = None # MULTI_TENANT_ENABLED=False 일 때 세션 사용자도 이 id 로 폴백
@property
def effective_user_id(self) -> int | None:
"""실제 DB 필터/컨텍스트 조회에 쓸 user_id.
MULTI_TENANT_ENABLED=True 면 본인 id 그대로, False 면 legacy owner id
(본인이 곧 legacy owner 인 세션 사용자·API 키 호출자는 동일).
"""
if settings.MULTI_TENANT_ENABLED:
return self.id
return self._legacy_owner_id if self._legacy_owner_id is not None else self.id
async def get_legacy_owner(db: AsyncSession) -> User | None:
"""is_admin=True 이고 id 가 가장 작은 사용자(레거시 단일 계좌 소유자). 없으면 None."""
result = await db.execute(
select(User).where(User.is_admin.is_(True)).order_by(User.id.asc()).limit(1)
)
return result.scalar_one_or_none()
async def _resolve_session_user(credential: str, db: AsyncSession) -> User | None:
"""auth.py._resolve_user 와 동일 로직(순환 임포트 회피를 위해 여기서 재사용)."""
from app.core.security import verify_session_token
username = verify_session_token(credential)
if not username:
return None
result = await db.execute(select(User).where(User.username == username))
return result.scalar_one_or_none()
async def get_current_user(
credential: str = Depends(require_api_key), db: AsyncSession = Depends(get_db)
) -> CurrentUser:
"""세션 토큰 → User. API 키 → legacy owner(없으면 id=None, is_api_key=True)."""
legacy_owner = await get_legacy_owner(db)
legacy_owner_id = legacy_owner.id if legacy_owner else None
if hmac.compare_digest(credential.encode(), _get_api_key().encode()):
if legacy_owner is not None:
return CurrentUser(
id=legacy_owner.id,
username=legacy_owner.username,
is_admin=True,
is_api_key=True,
_legacy_owner_id=legacy_owner_id,
)
return CurrentUser(id=None, username="api-key", is_admin=True, is_api_key=True, _legacy_owner_id=None)
user = await _resolve_session_user(credential, db)
if user is not None and user.is_active:
return CurrentUser(
id=user.id,
username=user.username,
is_admin=user.is_admin,
is_api_key=False,
_legacy_owner_id=legacy_owner_id,
)
# 세션 사용자가 없거나(계정 삭제) 비활성(회수/미승인)이면 legacy owner 로 폴백하지 않고
# 명시적으로 거부한다(H2) — require_api_key 는 JWT 서명/exp/aud 만 검증하고 is_active 를
# 보지 않으므로, revoke 직후 토큰 만료 전까지는 이 분기가 유일한 방어선이다.
if user is not None and not user.is_active:
logger.warning("[TenantAuth] 비활성 사용자 세션 토큰 사용 시도: user=%s", user.username)
else:
logger.warning("[TenantAuth] require_api_key 통과 후 사용자 해석 실패(계정 삭제 등)")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="세션이 유효하지 않습니다.")
async def deny_non_owner_session_when_mt(user: CurrentUser = Depends(get_current_user)) -> None:
"""M5: `MULTI_TENANT_ENABLED=True`일 때 legacy 전용 라우터(`portfolio`/`trades`/
`manual_buy` 등, `/portfolio/v2`·`/trades/v2` 로 전환되지 않은 구 엔드포인트)를
legacy owner가 아닌 세션 사용자가 호출하지 못하게 막는다.
- API 키 호출자(`is_api_key=True`, 예: MCP 서버)와 legacy owner 본인은 통과.
- `MULTI_TENANT_ENABLED=False`(기본)이면 하위 호환을 위해 항상 통과.
- 기존 `require_api_key` 의존성 위에 추가로 거는 것이므로 라우터 시그니처는 바꾸지 않는다.
"""
if not settings.MULTI_TENANT_ENABLED:
return
if user.is_api_key:
return
if user._legacy_owner_id is not None and user.id == user._legacy_owner_id:
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="멀티테넌트 모드에서는 본인 계좌 API(/portfolio/v2, /trades/v2)를 사용하세요.",
)
async def require_current_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
"""관리자 전용 `_v2` 엔드포인트에서 사용. auth.py.require_admin 과 별도(반환 타입이 CurrentUser)."""
from fastapi import HTTPException, status
if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="관리자 권한이 필요합니다.")
return user

View File

@@ -87,6 +87,27 @@ async def lifespan(app: FastAPI):
await conn.run_sync(Base.metadata.create_all)
logger.info("✅ Database tables created successfully")
# ── H1: alembic 마이그레이션 누락 조기 감지 ──────────────────────────
# `create_all()`은 신규 테이블만 만들고 기존 테이블에 컬럼을 추가하지 않는다.
# 멀티테넌트 1단계는 portfolio 등 기존 테이블에 `user_id` 컬럼을 추가했으므로,
# 배포 시 `alembic upgrade head`를 건너뛰면 이 컬럼이 없는 채로 기동돼
# SQLAlchemy가 매핑된 모든 컬럼을 SELECT/INSERT하다 UndefinedColumnError로
# 실패한다(자동매매 기록 전체 유실). 여기서 명시적으로 기동을 중단시킨다.
from sqlalchemy import text as _sa_text_check
async with engine.connect() as _check_conn:
_col_exists = (await _check_conn.execute(_sa_text_check(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name='portfolio' AND column_name='user_id'"
))).fetchone()
if _col_exists is None:
logger.critical(
"❌ [H1] portfolio.user_id 컬럼이 없습니다 — alembic 마이그레이션이 적용되지 않았습니다. "
"배포 절차 위반: `cd backend && alembic upgrade head` 를 먼저 실행한 뒤 다시 기동하세요."
)
raise RuntimeError(
"Migration required: portfolio.user_id column missing. Run `alembic upgrade head` before starting the app."
)
try:
from app.services.loss_tracker import loss_tracker
async with AsyncSessionLocal() as session:
@@ -159,6 +180,13 @@ async def lifespan(app: FastAPI):
# CLI(`alembic upgrade head`)로 수행한다. 따라서 여기서는 호출하지 않는다.
logger.info("ℹ️ Alembic auto-migration skipped (schema via create_all; migrations run via CLI)")
# ── 멀티테넌트 부트스트랩: 관리자 계정 시드 + legacy KIS 자격증명 이관 + user_id 백필 ──
try:
from app.services.tenant.bootstrap import ensure_bootstrap_admin
await ensure_bootstrap_admin()
except Exception as e:
logger.warning(f"⚠️ 멀티테넌트 부트스트랩 실패 (non-critical): {e}")
# ── KRX 일봉 데이터 초기 수집 (DB 비어있을 때만 백그라운드 실행) ──────
try:
from app.models.models import StockDailyPrice

View File

@@ -9,6 +9,8 @@ SQLAlchemy ORM 모델 정의
- ExchangeRate: 환율 이력
- UsStockUniverse: 미국 주식 종목 유니버스
- User: 대시보드 로그인 계정 (bcrypt 해시, 관리자 승인)
- UserKisCredential: 사용자별 KIS 자격증명 (암호화 저장, 멀티테넌트 1단계)
- StockBanV2: 사용자별 3연패 밴 (StockBan 은 legacy owner 전용 단일 테이블로 유지)
"""
from datetime import datetime, date
from typing import Optional, List
@@ -40,8 +42,10 @@ class StockDailyPrice(Base):
class TradingSession(Base):
"""🔥 일일 트레이딩 세션 관리 (리스크 관리)"""
__tablename__ = "trading_sessions"
id = Column(Integer, primary_key=True, index=True)
# 멀티테넌트 1단계: nullable — 기존 session_date unique 는 유지 (0012에서 (user_id, session_date)로 전환)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
session_date = Column(Date, default=date.today, unique=True, index=True)
consecutive_losses = Column(Integer, default=0)
@@ -122,6 +126,8 @@ class TradeHistory(Base):
__tablename__ = "trade_history"
id = Column(Integer, primary_key=True, index=True)
# 멀티테넌트 1단계: nullable — MULTI_TENANT_ENABLED=False 인 동안은 채우지 않음(legacy 컨텍스트 조회 시 필터 미적용)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
rule_id = Column(Integer)
stock_code = Column(String(20), nullable=False, index=True)
stock_name = Column(String(100))
@@ -155,6 +161,8 @@ class Portfolio(Base):
__tablename__ = "portfolio"
id = Column(Integer, primary_key=True, index=True)
# 멀티테넌트 1단계: nullable, 기존 unique(stock_code)는 이번 단계에서 유지 (0012에서 (user_id, stock_code)로 전환)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
stock_code = Column(String(20), nullable=False, unique=True)
stock_name = Column(String(100))
quantity = Column(Integer, nullable=False, default=0)
@@ -201,8 +209,9 @@ class News(Base):
class ClaudeAnalysisLog(Base):
__tablename__ = "claude_analysis_log"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) # 감사 추적용, 필수 아님
rule_id = Column(Integer)
stock_code = Column(String(20))
analysis_type = Column(String(50))
@@ -215,8 +224,9 @@ class ClaudeAnalysisLog(Base):
class MCPToolCall(Base):
__tablename__ = "mcp_tool_calls"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) # 감사 추적용, 필수 아님
tool_name = Column(String(100), nullable=False)
parameters = Column(JSONB)
result = Column(JSONB)
@@ -264,6 +274,8 @@ class ActiveTrade(Base):
__tablename__ = "active_trades"
id = Column(Integer, primary_key=True, index=True)
# 멀티테넌트 1단계: nullable — db_worker upsert 키에 user_id 추가는 2단계
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
stock_code = Column(String(20), nullable=False, index=True) # [FIX] 인덱스 추가
stock_name = Column(String(100))
quantity = Column(Integer, nullable=False)
@@ -317,6 +329,7 @@ class TradeJournal(Base):
__tablename__ = "trade_journals"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
trade_id = Column(Integer)
stock_code = Column(String(20), nullable=False, index=True)
stock_name = Column(String(100))
@@ -331,6 +344,8 @@ class TradeJournal(Base):
class StockBan(Base):
"""레거시(단일 계좌) 3연패 밴 테이블. PK=stock_code 라 사용자별 분리 불가 →
멀티테넌트에서는 [[StockBanV2]] 사용, 이 테이블은 legacy owner 전용으로 유지."""
__tablename__ = "stock_bans"
stock_code = Column(String(20), primary_key=True)
@@ -339,6 +354,26 @@ class StockBan(Base):
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class StockBanV2(Base):
"""사용자별 3연패 밴 (멀티테넌트). stock_bans 와 달리 PK 대신 (user_id, stock_code) unique.
§3.2 근거: stock_bans.PK=stock_code 라 사용자 분리 시 PK 변경이 필요한데, 이는
기존 시그니처 변경에 해당하므로 새 테이블로 분리. 0011 백필로 stock_bans → 여기로
legacy owner 행을 복제한다(양쪽 유지, legacy 경로는 계속 stock_bans 사용).
"""
__tablename__ = "stock_bans_v2"
__table_args__ = (
UniqueConstraint("user_id", "stock_code", name="uq_stock_bans_v2_user_stock"),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
stock_code = Column(String(20), nullable=False, index=True)
consecutive_losses = Column(Integer, default=0, nullable=False)
ban_until = Column(DateTime, nullable=True)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class WatchlistStock(Base):
__tablename__ = "watchlist_stocks"
@@ -403,3 +438,29 @@ class User(Base):
is_admin = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
last_login_at = Column(DateTime, nullable=True)
class UserKisCredential(Base):
"""사용자별 KIS(한국투자증권) 자격증명 (멀티테넌트 1단계).
- app_key/app_secret/account_no 는 [[app.core.crypto]] 로 암호화해 *_enc 컬럼에 저장.
- app_key_hash: 동일 앱키 중복 등록 방지용 sha256 hex (평문 앱키의 해시, 조회만 목적 — 복호화 불가).
- 1단계는 사용자당 1세트(user_id unique). 모의/실전 동시 등록은 2단계에서 UniqueConstraint(user_id, mode)로 완화.
"""
__tablename__ = "user_kis_credentials"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
mode = Column(String(10), nullable=False) # virtual | real
app_key_enc = Column(Text, nullable=False)
app_secret_enc = Column(Text, nullable=False)
account_no_enc = Column(Text, nullable=False) # 하이픈 제거 10자리 (계좌 8자리 + 상품코드 2자리)
account_product_code = Column(String(2), nullable=False, default="01")
app_key_hash = Column(String(64), unique=True, nullable=False) # sha256(app_key) hex — 중복 등록 방지
app_key_hint = Column(String(8), nullable=True) # 마스킹 표시용 앞 6자
is_enabled = Column(Boolean, default=False, nullable=False) # 자동매매 대상 여부 (연결 테스트 성공 시 True 허용)
auto_trade_enabled = Column(Boolean, default=False, nullable=False)
last_verified_at = Column(DateTime, nullable=True)
key_version = Column(Integer, default=1, nullable=False) # 암호화 키 로테이션용
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

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 상태면 자동 취소.

3
backend/pytest.ini Normal file
View File

@@ -0,0 +1,3 @@
[pytest]
markers =
integration: 컨테이너 Postgres(+fakeredis/mock KIS)가 필요한 통합 테스트 — 라이브 DB/Redis/KIS에 접근하지 않음

View File

@@ -13,6 +13,7 @@ websockets==12.0
httpx>=0.27.1
aiohttp>=3.9.0
python-jose[cryptography]==3.3.0
cryptography>=42 # user_kis_credentials 암복호화 (Fernet) — python-jose extras 로 이미 설치되나 명시적 의존성으로 고정
passlib[bcrypt]==1.7.4
python-multipart>=0.0.9
anthropic>=0.18.0

View File

View File

@@ -0,0 +1,91 @@
"""통합 테스트(D그룹) 공용 fixture — 격리된 임시 DB 전용.
H4: `test_multi_tenant.py`와 `test_kis_credentials.py`가 각자 정의하던 이벤트 루프/
lifespan/스키마 생성/DB 가드/HTTP 클라이언트 fixture를 이 파일로 통합한다. 두 파일
모두 이 conftest를 통해 동일한 가드를 거치므로, `tests/integration` 디렉터리를
통째로 실행해도(예: `pytest tests/integration -m integration`) users 테이블 등
스키마가 없어 실패하는 일이 없다.
가드: `settings.DATABASE_URL`의 DB 이름이 `_test`로 끝나지 않으면 즉시
`pytest.exit()` — 라이브 DB(예: 포트 15433의 `autostock_db`)를 실수로 겨냥한
실행을 세션 시작 단계에서 차단한다. `os.environ["DATABASE_URL"]`만 보던 이전 가드는
env 미설정 시 `settings.DATABASE_URL` 기본값(라이브 가능성)으로 조용히 새는 결함이
있었다(구 검수 H4).
실행 예시 (임시 Postgres 컨테이너 안에서):
docker run --rm --network host \
-v $(pwd)/backend:/app -w /app \
-e DATABASE_URL=postgresql://autostock:test@127.0.0.1:15499/autostock_test \
-e SECRET_KEY=test-secret-key-for-integration-tests-32chars \
-e CREDENTIALS_ENCRYPTION_KEY=test-cred-key-for-integration-tests-32c \
-e BOOTSTRAP_ADMIN_PASSWORD=bootstrap-pw \
auto-stock-backend:latest \
bash -lc "pip install -q fakeredis pytest-asyncio httpx && python -m pytest tests/integration -m integration -v"
"""
import asyncio
import os
import sys
from contextlib import asynccontextmanager
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
import httpx
import pytest
import pytest_asyncio
from httpx import ASGITransport
from app.core.config import settings # noqa: E402
# ── 임시 DB 강제 확인 — DB 이름이 `_test`로 끝나지 않으면 세션 시작 자체를 중단 ──
_db_url = settings.DATABASE_URL or ""
_db_name = _db_url.rsplit("/", 1)[-1].split("?", 1)[0]
if not _db_name.endswith("_test"):
pytest.exit(
f"settings.DATABASE_URL의 DB 이름이 '_test'로 끝나지 않습니다(현재: {_db_name!r}). "
"통합 테스트는 반드시 임시 DB(예: autostock_test)를 대상으로 실행해야 합니다.",
returncode=1,
)
@pytest.fixture(scope="session")
def event_loop():
"""세션 전체가 동일 이벤트 루프를 공유하도록 강제 (asyncpg 풀 재사용).
기본(함수 스코프) 이벤트 루프를 쓰면 테스트마다 새 루프가 생성되는데, 앱 임포트 시
한 번 만들어진 asyncpg 커넥션 풀(`AsyncSessionLocal`)의 유휴 커넥션이 이전 루프에
묶여 있어 다음 테스트에서 "Event loop is closed" 오류가 난다.
"""
loop = asyncio.new_event_loop()
yield loop
loop.close()
@asynccontextmanager
async def _noop_lifespan(app):
"""실거래 스케줄러/db_writer_worker/부트스트랩 자동 기동 방지 — 필요한 것만 테스트가 직접 기동."""
yield
from app.core.database import Base, engine # noqa: E402
from app.main import app as fastapi_app # noqa: E402
fastapi_app.router.lifespan_context = _noop_lifespan
@pytest_asyncio.fixture(scope="session", autouse=True)
async def _create_schema():
"""임시 DB이므로 alembic 대신 `Base.metadata.create_all`로 스키마를 통째로 생성."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
@pytest_asyncio.fixture
async def client():
transport = ASGITransport(app=fastapi_app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
def _auth(token: str) -> dict:
return {"X-API-Key": token}

View File

@@ -0,0 +1,202 @@
"""KIS 자격증명 API 통합 테스트 (`kis_credentials.py`, `auth.py` 이관 API, D그룹 격리 환경).
- 격리 원칙: `test_multi_tenant.py`와 동일하게 **임시 Postgres 컨테이너**(DATABASE_URL이
`_test`로 끝나야 함, `conftest.py`가 세션 시작 시 가드)에서만 실행한다. 라이브
`autostock-postgres`/`autostock-backend`에는 어떤 쓰기 경로도 호출하지 않는다(구 검수 H4:
이전 버전은 운영 DB를 직접 대상으로 했었음 — 수정됨).
- 이벤트 루프/lifespan/스키마 생성/HTTP 클라이언트/`_auth` 헬퍼는 `conftest.py`(이 디렉터리
공용, `test_multi_tenant.py`와 동일)가 제공한다.
- 테스트가 생성한 사용자/자격증명은 각 fixture teardown에서 직접 삭제한다.
- KIS 실계좌 호출(verify)은 `KoreaInvestmentAPI.for_user`를 monkeypatch 하여 네트워크를
타지 않는다.
실행 예시 (임시 Postgres 컨테이너 안에서, `test_multi_tenant.py` docstring 참고):
python -m pytest tests/integration/test_kis_credentials.py -m integration -v
"""
import time
import pytest
import pytest_asyncio
from sqlalchemy import delete
pytestmark = pytest.mark.integration
from app.core.database import AsyncSessionLocal # noqa: E402
from app.core.security import _get_api_key, create_session_token # noqa: E402
from app.models.models import User, UserKisCredential # noqa: E402
from app.services.korea_investment_api import KoreaInvestmentAPI # noqa: E402
from app.services.tenant.kis_pool import kis_client_pool # noqa: E402
from app.api.endpoints import kis_credentials as kis_credentials_module # noqa: E402
from .conftest import _auth # noqa: E402
_SUFFIX = str(int(time.time()))
class _FakeKis:
"""`KoreaInvestmentAPI.for_user`가 반환할 가짜 인스턴스 (verify 단위 테스트용)."""
def __init__(self, rt_cd="0", raise_exc=None):
self._rt_cd = rt_cd
self._raise_exc = raise_exc
self.closed = False
async def get_balance(self):
if self._raise_exc:
raise self._raise_exc
return {
"rt_cd": self._rt_cd,
"msg1": "정상처리" if self._rt_cd == "0" else "계좌 조회 실패",
"output2": [{"dnca_tot_amt": "1000000", "tot_evlu_amt": "5000000", "scts_evlu_amt": "4000000"}],
}
async def close(self):
self.closed = True
@pytest_asyncio.fixture
async def two_users():
"""테스트용 사용자 2명 생성 + 세션 토큰 발급. 종료 시 자격증명/사용자 완전 삭제."""
async with AsyncSessionLocal() as db:
u1 = User(username=f"it_kis_a_{_SUFFIX}", password_hash="x", is_active=True, is_admin=False)
u2 = User(username=f"it_kis_b_{_SUFFIX}", password_hash="x", is_active=True, is_admin=False)
db.add_all([u1, u2])
await db.commit()
await db.refresh(u1)
await db.refresh(u2)
token1, _ = create_session_token(u1.username)
token2, _ = create_session_token(u2.username)
ids = [u1.id, u2.id]
yield {"u1_id": u1.id, "u1_token": token1, "u2_id": u2.id, "u2_token": token2}
async with AsyncSessionLocal() as db:
await db.execute(delete(UserKisCredential).where(UserKisCredential.user_id.in_(ids)))
await db.execute(delete(User).where(User.id.in_(ids)))
await db.commit()
for uid in ids:
await kis_client_pool.invalidate(uid)
kis_credentials_module._last_verify_attempt.pop(ids[0], None)
kis_credentials_module._last_verify_attempt.pop(ids[1], None)
@pytest.mark.asyncio
async def test_register_masked_view_duplicate_patch_delete(client, two_users):
u1_headers = _auth(two_users["u1_token"])
u2_headers = _auth(two_users["u2_token"])
# 최초 조회 — 미등록 404
r = await client.get("/api/v1/account/credentials", headers=u1_headers)
assert r.status_code == 404
body = {
"mode": "virtual",
"app_key": f"testappkey{_SUFFIX}",
"app_secret": "testsecret",
"account_no": "1234567801",
}
r = await client.put("/api/v1/account/credentials", headers=u1_headers, json=body)
assert r.status_code == 200, r.text
data = r.json()
assert data["mode"] == "virtual"
assert data["app_key_hint"] == body["app_key"][:6] + "…"
assert data["account_masked"] == "******" + "1234567801"[:8][-2:]
assert data["is_enabled"] is False
assert data["auto_trade_enabled"] is False
assert data["last_verified_at"] is None
# 평문이 응답에 없어야 함
assert body["app_secret"] not in r.text
assert body["app_key"] not in r.text
# 마스킹 조회
r = await client.get("/api/v1/account/credentials", headers=u1_headers)
assert r.status_code == 200
assert r.json()["account_masked"] == data["account_masked"]
# 잘못된 계좌번호 형식 (하이픈 제거 후 10자리 아님) → 422
bad_body = dict(body, account_no="12345")
r = await client.put("/api/v1/account/credentials", headers=u1_headers, json=bad_body)
assert r.status_code == 422
# 동일 앱키를 다른 사용자가 등록 → 409
dup_body = dict(body, account_no="9876543201")
r = await client.put("/api/v1/account/credentials", headers=u2_headers, json=dup_body)
assert r.status_code == 409
# 검증 전 자동매매 활성화 시도 → 400
r = await client.patch("/api/v1/account/auto-trade", headers=u1_headers, json={"enabled": True})
assert r.status_code == 400
# 자동매매 비활성화 요청은 검증 여부와 무관하게 허용
r = await client.patch("/api/v1/account/auto-trade", headers=u1_headers, json={"enabled": False})
assert r.status_code == 200
assert r.json()["auto_trade_enabled"] is False
# 삭제 → 204, 이후 조회 404
r = await client.delete("/api/v1/account/credentials", headers=u1_headers)
assert r.status_code == 204
r = await client.get("/api/v1/account/credentials", headers=u1_headers)
assert r.status_code == 404
@pytest.mark.asyncio
async def test_verify_success_failure_and_cooldown(client, two_users, monkeypatch):
headers = _auth(two_users["u1_token"])
body = {
"mode": "virtual",
"app_key": f"verifykey{_SUFFIX}",
"app_secret": "s",
"account_no": "1122334401",
}
r = await client.put("/api/v1/account/credentials", headers=headers, json=body)
assert r.status_code == 200
# 성공 케이스
monkeypatch.setattr(KoreaInvestmentAPI, "for_user", classmethod(lambda cls, **kw: _FakeKis(rt_cd="0")))
r = await client.post("/api/v1/account/credentials/verify", headers=headers)
assert r.status_code == 200, r.text
data = r.json()
assert data["ok"] is True
assert data["balance_summary"]["total_assets"] == 5000000.0
# 60초 쿨다운 이내 재시도 → 429
r = await client.post("/api/v1/account/credentials/verify", headers=headers)
assert r.status_code == 429
# 쿨다운 우회 후 실패 케이스(rt_cd != 0) → 400
kis_credentials_module._last_verify_attempt.pop(two_users["u1_id"], None)
monkeypatch.setattr(KoreaInvestmentAPI, "for_user", classmethod(lambda cls, **kw: _FakeKis(rt_cd="1")))
r = await client.post("/api/v1/account/credentials/verify", headers=headers)
assert r.status_code == 400
# 검증 성공 후에는 자동매매 활성화 허용
kis_credentials_module._last_verify_attempt.pop(two_users["u1_id"], None)
monkeypatch.setattr(KoreaInvestmentAPI, "for_user", classmethod(lambda cls, **kw: _FakeKis(rt_cd="0")))
r = await client.post("/api/v1/account/credentials/verify", headers=headers)
assert r.status_code == 200
r = await client.patch("/api/v1/account/auto-trade", headers=headers, json={"enabled": True})
assert r.status_code == 200
assert r.json()["auto_trade_enabled"] is True
@pytest.mark.asyncio
async def test_api_key_caller_maps_to_legacy_owner_or_400(client):
"""API 키(X-API-Key=SECRET_KEY) 호출자는 legacy owner에 매핑되거나(admin 존재),
admin이 전혀 없으면 400을 반환한다 — 어느 쪽이든 500이 아니어야 한다."""
r = await client.get("/api/v1/account/credentials", headers=_auth(_get_api_key()))
assert r.status_code in (200, 400, 404)
@pytest.mark.asyncio
async def test_admin_tenant_status_requires_admin(client, two_users):
# 일반 사용자는 관리자 API 접근 불가
r = await client.get("/api/v1/auth/admin/tenant/status", headers=_auth(two_users["u1_token"]))
assert r.status_code == 403
# API 키(SECRET_KEY)는 관리자로 간주되어 접근 가능
r = await client.get("/api/v1/auth/admin/tenant/status", headers=_auth(_get_api_key()))
assert r.status_code == 200
data = r.json()
assert "multi_tenant_enabled" in data
assert "null_user_id_counts" in data

View File

@@ -0,0 +1,964 @@
"""멀티테넌트(사용자별 매매) 1단계 통합 테스트 (D그룹, 격리 환경).
`docs/multi_tenant_design.md` §7 "테스트 전략" (a)~(h) 대응.
- 격리 원칙: 반드시 **임시 Postgres 컨테이너**(DATABASE_URL을 그 컨테이너로 지정)에서
실행한다. 라이브 `autostock-postgres`/`autostock-backend`/`autostock-redis`에는
어떤 쓰기·주문 경로도 호출하지 않는다. Redis는 `fakeredis`로 대체하고,
`KoreaInvestmentAPI`의 주문/잔고/`for_user` 계열은 전부 monkeypatch 로 가짜 구현을
주입한다(가드: `test_00_mocks_applied_guard`).
- 이벤트 루프/lifespan/스키마 생성/HTTP 클라이언트/DB 가드는 `conftest.py`(H4, 이 디렉터리
공용)가 제공한다 — `settings.DATABASE_URL`이 `_test`로 끝나지 않으면 세션 시작 시
`pytest.exit()`.
- 실행 예시 (임시 컨테이너 안에서):
docker run --rm --network host \
-v $(pwd)/backend:/app -w /app \
-e DATABASE_URL=postgresql://autostock:test@127.0.0.1:15499/autostock_test \
-e SECRET_KEY=test-secret-key-for-integration-tests-32chars \
-e CREDENTIALS_ENCRYPTION_KEY=test-cred-key-for-integration-tests-32c \
-e BOOTSTRAP_ADMIN_PASSWORD=bootstrap-pw \
auto-stock-backend:latest \
bash -lc "pip install -q fakeredis pytest-asyncio httpx && python -m pytest tests/integration/test_multi_tenant.py -m integration -v"
"""
import asyncio
import time
from datetime import datetime, timedelta, timezone
import pytest
import pytest_asyncio
from sqlalchemy import delete, text
pytestmark = pytest.mark.integration
from app.core.database import AsyncSessionLocal # noqa: E402
from app.core.security import _get_api_key, create_session_token # noqa: E402
from app.models.models import ( # noqa: E402
ActiveTrade,
Portfolio,
StockBanV2,
TradeHistory,
User,
UserKisCredential,
)
from app.services import db_worker as db_worker_module # noqa: E402
from app.services.db_worker import ( # noqa: E402
db_writer_worker,
enqueue_stock_ban_upsert_v2,
)
from app.services.korea_investment_api import KoreaInvestmentAPI # noqa: E402
from app.services.loss_tracker import ConsecutiveLossTracker # noqa: E402
from app.services.tenant import bootstrap as tenant_bootstrap # noqa: E402
from app.services.tenant.kis_pool import kis_client_pool # noqa: E402
from app.services.tenant.registry import user_context_registry # noqa: E402
from app.services.tenant.context import UserContext # noqa: E402
from app.services import trade_executor as trade_executor_module # noqa: E402
from app.core import redis as redis_module # noqa: E402
from app.api.endpoints import kis_credentials as kis_credentials_module # noqa: E402
from .conftest import _auth # noqa: E402
_SUFFIX = str(int(time.time()))
# ─────────────────────────────────────────────────────────────────────────────
# 가드 — mock이 실제로 적용됐는지 매 테스트 전에 확인 (§지시사항 필수 가드)
# ─────────────────────────────────────────────────────────────────────────────
class _FakeKis:
"""`KoreaInvestmentAPI` 대체 — 네트워크 호출을 절대 하지 않는다."""
def __init__(self, rt_cd="0", balance=None, order_id="FAKE0001"):
self._rt_cd = rt_cd
self._balance = balance or {
"rt_cd": "0",
"output1": [],
"output2": [{"dnca_tot_amt": "1000000", "tot_evlu_amt": "5000000", "scts_evlu_amt": "4000000"}],
}
self._order_id = order_id
self.place_order_calls = []
self.closed = False
async def get_balance(self):
return self._balance
async def get_overseas_balance(self):
return {"rt_cd": "0", "output1": [], "output2": [{}]}
async def get_current_price(self, stock_code):
return {"output": {"stck_prpr": "10000", "hts_kor_isnm": f"종목{stock_code}"}}
async def place_order(self, stock_code, order_type, quantity, price=None):
self.place_order_calls.append(
{"stock_code": stock_code, "order_type": order_type, "quantity": quantity, "price": price}
)
return {"rt_cd": self._rt_cd, "msg1": "정상처리", "output": {"ODNO": self._order_id}}
async def cancel_order(self, order_id, quantity):
return {"rt_cd": "0", "msg1": "정상처리"}
async def close(self):
self.closed = True
class _FakeMddAlwaysOpen:
async def can_open_new_position(self) -> bool:
return True
class _FakeLossTrackerNeverBanned:
async def is_banned_safe(self, stock_code: str) -> bool:
return False
@pytest.fixture(autouse=True)
def _guard_no_real_kis(monkeypatch):
"""모든 테스트에서 실제 KIS 호출 경로를 차단 — 클래스 메서드 자체를 막아 우회 불가하게 함."""
async def _blocked(*args, **kwargs):
raise AssertionError("실제 KIS API 호출이 시도되었습니다 — 테스트에서는 반드시 mock 되어야 합니다")
monkeypatch.setattr(KoreaInvestmentAPI, "place_order", _blocked, raising=False)
monkeypatch.setattr(KoreaInvestmentAPI, "place_overseas_order", _blocked, raising=False)
yield
def test_00_mocks_applied_guard(monkeypatch):
"""가드: `safe_place_order_v2`가 실제로 `ctx.kis`(fake)를 호출하고, 전역
`KoreaInvestmentAPI.place_order`는 호출되지 않는지 사전 확인."""
fake = _FakeKis()
assert asyncio.run(fake.place_order("005930", "BUY", 1)) is not None
# 전역 클래스 메서드는 fixture에서 차단됨 — 직접 호출하면 AssertionError
inst = KoreaInvestmentAPI.__new__(KoreaInvestmentAPI)
with pytest.raises(AssertionError):
asyncio.run(KoreaInvestmentAPI.place_order(inst, "005930", "BUY", 1))
# ─────────────────────────────────────────────────────────────────────────────
# 공용 fixture
# ─────────────────────────────────────────────────────────────────────────────
@pytest_asyncio.fixture
async def two_users():
"""테스트용 사용자 2명(A=admin/legacy owner, B=일반) 생성 + 세션 토큰. 종료 시 완전 삭제."""
async with AsyncSessionLocal() as db:
u_a = User(username=f"mt_a_{_SUFFIX}", password_hash="x", is_active=True, is_admin=True)
u_b = User(username=f"mt_b_{_SUFFIX}", password_hash="x", is_active=True, is_admin=False)
db.add_all([u_a, u_b])
await db.commit()
await db.refresh(u_a)
await db.refresh(u_b)
token_a, _ = create_session_token(u_a.username)
token_b, _ = create_session_token(u_b.username)
ids = [u_a.id, u_b.id]
yield {"a_id": u_a.id, "a_token": token_a, "a_user": u_a.username, "b_id": u_b.id, "b_token": token_b}
async with AsyncSessionLocal() as db:
await db.execute(delete(StockBanV2).where(StockBanV2.user_id.in_(ids)))
await db.execute(delete(ActiveTrade).where(ActiveTrade.user_id.in_(ids)))
await db.execute(delete(TradeHistory).where(TradeHistory.user_id.in_(ids)))
await db.execute(delete(Portfolio).where(Portfolio.user_id.in_(ids)))
await db.execute(delete(UserKisCredential).where(UserKisCredential.user_id.in_(ids)))
await db.execute(delete(User).where(User.id.in_(ids)))
await db.commit()
for uid in ids:
await kis_client_pool.invalidate(uid)
await user_context_registry.invalidate(uid)
kis_credentials_module._last_verify_attempt.pop(ids[0], None)
kis_credentials_module._last_verify_attempt.pop(ids[1], None)
async def _run_worker_and_wait(task: dict, check, timeout: float = 5.0):
"""db_writer_worker를 짧게 기동해 task 1건을 처리시키고, `check()`가 True가
될 때까지 폴링한 뒤 워커를 취소한다.
db_writer_worker는 무한 루프 + task_done() 미호출 구조라 queue.join()으로
동기화할 수 없으므로, 결과가 DB에 반영됐는지 폴링으로 확인한다.
"""
queue = db_worker_module.get_db_task_queue()
worker = asyncio.create_task(db_writer_worker())
try:
await queue.put(task)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if await check():
return
await asyncio.sleep(0.05)
raise AssertionError(f"db_worker task {task.get('type')} 결과를 {timeout}s 내에 확인하지 못함")
finally:
worker.cancel()
try:
await worker
except asyncio.CancelledError:
pass
# ─────────────────────────────────────────────────────────────────────────────
# (a) 두 사용자 register/login → PUT /account/credentials → 마스킹 조회 → 동일 앱키 409
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_a_register_login_credentials_duplicate_appkey(client, monkeypatch):
from app.core.config import settings
monkeypatch.setattr(settings, "SIGNUP_ENABLED", True)
monkeypatch.setattr(settings, "SIGNUP_AUTO_APPROVE", True)
uname1 = f"mt_reg_a_{_SUFFIX}"
uname2 = f"mt_reg_b_{_SUFFIX}"
try:
r = await client.post("/api/v1/auth/register", json={"username": uname1, "password": "Passw0rd!123"})
assert r.status_code == 201, r.text
r = await client.post("/api/v1/auth/register", json={"username": uname2, "password": "Passw0rd!123"})
assert r.status_code == 201, r.text
r = await client.post("/api/v1/auth/login", json={"username": uname1, "password": "Passw0rd!123"})
assert r.status_code == 200, r.text
token1 = r.json()["token"]
r = await client.post("/api/v1/auth/login", json={"username": uname2, "password": "Passw0rd!123"})
assert r.status_code == 200, r.text
token2 = r.json()["token"]
h1, h2 = _auth(token1), _auth(token2)
body = {"mode": "virtual", "app_key": f"regtestkey{_SUFFIX}", "app_secret": f"regsecret{_SUFFIX}", "account_no": "1111111101"}
r = await client.put("/api/v1/account/credentials", headers=h1, json=body)
assert r.status_code == 200, r.text
data = r.json()
assert data["app_key_hint"] == body["app_key"][:6] + "…"
assert body["app_key"] not in r.text and body["app_secret"] not in r.text
r = await client.get("/api/v1/account/credentials", headers=h1)
assert r.status_code == 200
assert r.json()["app_key_hint"] == data["app_key_hint"]
dup = dict(body, account_no="2222222201")
r = await client.put("/api/v1/account/credentials", headers=h2, json=dup)
assert r.status_code == 409
finally:
async with AsyncSessionLocal() as db:
uids_res = await db.execute(select_users_by_username(uname1, uname2))
uids = [row[0] for row in uids_res]
await db.execute(delete(UserKisCredential).where(UserKisCredential.user_id.in_(uids)))
await db.execute(delete(User).where(User.id.in_(uids)))
await db.commit()
for uid in uids:
await kis_client_pool.invalidate(uid)
def select_users_by_username(*usernames):
from sqlalchemy import select as _select
return _select(User.id).where(User.username.in_(usernames))
# ─────────────────────────────────────────────────────────────────────────────
# (b) upsert_portfolio — user_id A/B로 동일 종목 2행 공존 (0012 이전 알려진 제약 → xfail)
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
@pytest.mark.xfail(
strict=True,
reason=(
"설계 §3.2/§7: portfolio.stock_code 는 0012 마이그레이션 전까지 전역 unique 유지 — "
"서로 다른 user_id로 동일 stock_code를 upsert하면 두 번째 INSERT가 UniqueViolation으로 실패한다."
),
)
async def test_b_upsert_portfolio_same_stock_two_users_conflict(two_users):
stock_code = f"9{_SUFFIX[-5:]}" # 6자리 임시 코드
async def _row_exists(user_id: int) -> bool:
async with AsyncSessionLocal() as db:
from sqlalchemy import select as _select
res = await db.execute(
_select(Portfolio).where(Portfolio.user_id == user_id, Portfolio.stock_code == stock_code)
)
return res.scalar_one_or_none() is not None
await _run_worker_and_wait(
{
"type": "upsert_portfolio",
"data": {
"user_id": two_users["a_id"], "stock_code": stock_code, "stock_name": "테스트",
"quantity": 10, "average_price": 1000, "current_price": 1000,
"market": "KRX", "strategy_type": "manual",
},
},
lambda: _row_exists(two_users["a_id"]),
)
# 서로 다른 user_id로 동일 stock_code upsert 시도 — unique(stock_code) 위반으로 두 번째 행이 생기지 않아야
# "성공"(xfail 대상)하지만, 실제로는 IntegrityError 로 조용히 실패(예외를 삼키고 재시도 로직을 태움)한다.
await _run_worker_and_wait(
{
"type": "upsert_portfolio",
"data": {
"user_id": two_users["b_id"], "stock_code": stock_code, "stock_name": "테스트",
"quantity": 5, "average_price": 2000, "current_price": 2000,
"market": "KRX", "strategy_type": "manual",
},
},
lambda: _row_exists(two_users["b_id"]),
timeout=2.0,
)
async with AsyncSessionLocal() as db:
from sqlalchemy import select as _select
res = await db.execute(_select(Portfolio).where(Portfolio.stock_code == stock_code))
rows = res.scalars().all()
assert len(rows) == 2, "두 사용자가 동일 종목을 공존 보유해야 한다 (0012 이전에는 실패가 알려진 제약)"
# ─────────────────────────────────────────────────────────────────────────────
# (c) upsert_stock_ban_v2 — 사용자별 ban 분리 + load_from_db_v2 복원
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_c_stock_ban_v2_upsert_tz_aware_datetime_bug(two_users):
"""실제 loss_tracker 경로(tz-aware ban_until)로 upsert_stock_ban_v2를 태워도
db_worker가 저장 직전 naive-UTC로 정규화해 DB write가 성공해야 한다
(수정 전에는 asyncpg DataError로 DLQ에 빠졌던 결함)."""
stock_code = "005930"
ban_until_a = datetime.now(timezone.utc) + timedelta(days=42) # 실제 코드와 동일하게 tz-aware
async def _ban_exists(user_id: int) -> bool:
async with AsyncSessionLocal() as db:
from sqlalchemy import select as _select
res = await db.execute(
_select(StockBanV2).where(StockBanV2.user_id == user_id, StockBanV2.stock_code == stock_code)
)
return res.scalar_one_or_none() is not None
await enqueue_stock_ban_upsert_v2(two_users["a_id"], stock_code, 3, ban_until_a)
worker = asyncio.create_task(db_writer_worker())
try:
deadline = time.monotonic() + 8 # 지수 백오프(1→2→4s) 3회 재시도를 기다림
while time.monotonic() < deadline and not await _ban_exists(two_users["a_id"]):
await asyncio.sleep(0.1)
assert await _ban_exists(two_users["a_id"])
finally:
worker.cancel()
try:
await worker
except asyncio.CancelledError:
pass
@pytest.mark.asyncio
async def test_c_stock_ban_v2_load_from_db_v2_per_user_isolation(two_users):
"""읽기 경로(load_from_db_v2)만 검증 — 위 결함(tz-aware 쓰기)과 독립적으로,
tz-naive 값을 직접 INSERT해 사용자별 격리가 올바른지 확인한다."""
stock_code = "005930"
ban_until_a = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(days=42)
async with AsyncSessionLocal() as db:
db.add(StockBanV2(user_id=two_users["a_id"], stock_code=stock_code, consecutive_losses=3, ban_until=ban_until_a))
await db.commit()
tracker_a = ConsecutiveLossTracker(ban_count=3, ban_calendar_days=42)
tracker_b = ConsecutiveLossTracker(ban_count=3, ban_calendar_days=42)
async with AsyncSessionLocal() as db:
await tracker_a.load_from_db_v2(db, two_users["a_id"])
await tracker_b.load_from_db_v2(db, two_users["b_id"])
assert await tracker_a.is_banned_safe(stock_code) is True
assert await tracker_b.is_banned_safe(stock_code) is False
# ─────────────────────────────────────────────────────────────────────────────
# (c-legacy) save_to_db() → load_from_db() 라운드트립 — 레거시 stock_bans도
# tz-aware ban_until을 안전하게 저장·복원해야 한다 (db_worker.py fix 회귀 방지)
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_c_legacy_stock_ban_save_load_roundtrip_tz_aware():
from app.models.models import StockBan
stock_code = f"8{_SUFFIX[-5:]}" # 6자리 임시 코드, stock_bans PK 충돌 회피
async def _ban_row(db):
from sqlalchemy import select as _select
res = await db.execute(_select(StockBan).where(StockBan.stock_code == stock_code))
return res.scalar_one_or_none()
tracker = ConsecutiveLossTracker(ban_count=3, ban_calendar_days=42)
tracker.record_trade_result(stock_code, is_loss=True)
tracker.record_trade_result(stock_code, is_loss=True)
tracker.record_trade_result(stock_code, is_loss=True) # 3연패 → tz-aware ban_until 생성
worker = asyncio.create_task(db_writer_worker())
try:
await tracker.save_to_db(stock_code=stock_code)
deadline = time.monotonic() + 5.0
row = None
while time.monotonic() < deadline:
async with AsyncSessionLocal() as db:
row = await _ban_row(db)
if row is not None:
break
await asyncio.sleep(0.05)
assert row is not None, "save_to_db()가 stock_bans에 tz-aware ban_until을 정규화해 저장해야 한다"
assert row.ban_until.tzinfo is None, "stock_bans.ban_until은 naive-UTC로 저장돼야 한다"
finally:
worker.cancel()
try:
await worker
except asyncio.CancelledError:
pass
reloaded = ConsecutiveLossTracker(ban_count=3, ban_calendar_days=42)
async with AsyncSessionLocal() as db:
await reloaded.load_from_db(db)
assert reloaded.is_banned(stock_code) is True
async with AsyncSessionLocal() as db:
await db.execute(delete(StockBan).where(StockBan.stock_code == stock_code))
await db.commit()
# ─────────────────────────────────────────────────────────────────────────────
# (d) safe_place_order_v2 락 키 — fakeredis로 user_id별 키가 다르게 생성되는지
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_d_order_lock_key_namespaced_by_user(monkeypatch, two_users):
fakeredis = pytest.importorskip("fakeredis")
fake_client = fakeredis.aioredis.FakeRedis(decode_responses=True)
monkeypatch.setattr(redis_module, "_redis_client", fake_client)
ctx_a = UserContext(
user_id=two_users["a_id"], username="a", mode="virtual",
kis=_FakeKis(), mdd=None, loss_tracker=None, auto_trade_enabled=True, is_legacy=False,
)
ctx_b = UserContext(
user_id=two_users["b_id"], username="b", mode="virtual",
kis=_FakeKis(), mdd=None, loss_tracker=None, auto_trade_enabled=True, is_legacy=False,
)
seen_keys = []
orig_set = fake_client.set
async def _tracking_set(key, *a, **kw):
seen_keys.append(key)
return await orig_set(key, *a, **kw)
monkeypatch.setattr(fake_client, "set", _tracking_set)
r_a = await trade_executor_module.safe_place_order_v2(ctx_a, "005930", "BUY", 1)
r_b = await trade_executor_module.safe_place_order_v2(ctx_b, "005930", "BUY", 1)
assert r_a["rt_cd"] == "0" and r_b["rt_cd"] == "0"
assert ctx_a.kis.place_order_calls and ctx_b.kis.place_order_calls
expected_a = f"order_lock:{two_users['a_id']}:005930:BUY"
expected_b = f"order_lock:{two_users['b_id']}:005930:BUY"
assert expected_a in seen_keys
assert expected_b in seen_keys
assert expected_a != expected_b
await fake_client.aclose()
# ─────────────────────────────────────────────────────────────────────────────
# (e) MULTI_TENANT_ENABLED=False 에서 GET /portfolio/v2 == GET /portfolio (레거시 호환)
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_e_legacy_parity_portfolio_v2_matches_portfolio(client, monkeypatch):
from app.core.config import settings
from app.api.endpoints import portfolio as portfolio_module
from app.services import korea_investment_api as kis_module
monkeypatch.setattr(settings, "MULTI_TENANT_ENABLED", False)
# effective_user_id가 legacy owner id를 반환하려면 admin(=legacy owner)이 최소 1명 있어야 함
# (설계상 admin 0명이면 400 — 이 테스트는 그 케이스가 아니라 "정상 legacy 경로"를 검증)
async with AsyncSessionLocal() as db:
legacy_admin = User(username=f"mt_legacy_owner_{_SUFFIX}", password_hash="x", is_active=True, is_admin=True)
db.add(legacy_admin)
await db.commit()
await db.refresh(legacy_admin)
legacy_admin_id = legacy_admin.id
# 이전 테스트가 캐싱한 legacy owner_id(0/None)를 무효화 — invalidate_all()은
# 사용자별 캐시만 지우고 legacy 캐시(TTL 60s)는 건드리지 않으므로 직접 리셋한다.
user_context_registry._legacy_ctx = None
user_context_registry._legacy_checked_at = 0.0
async def _fake_get_current_price(self, code):
return {"output": {"stck_prpr": "55000", "hts_kor_isnm": "삼성전자우"}}
monkeypatch.setattr(KoreaInvestmentAPI, "get_current_price", _fake_get_current_price, raising=False)
stock_code = "005935"
fake_balance = {
"rt_cd": "0",
"output1": [
{
"pdno": stock_code, "hldg_qty": "3", "pchs_avg_pric": "50000",
"prpr": "55000", "evlu_pfls_amt": "15000", "evlu_pfls_rt": "10.0",
"prdt_name": "삼성전자우",
}
],
"output2": [{"dnca_tot_amt": "1000000", "tot_evlu_amt": "5000000"}],
}
async def _fake_get_balance():
return fake_balance
async def _fake_get_overseas_balance():
return {"rt_cd": "0", "output1": [], "output2": [{}]}
monkeypatch.setattr(kis_module.korea_investment_api, "get_balance", _fake_get_balance)
monkeypatch.setattr(kis_module.korea_investment_api, "get_overseas_balance", _fake_get_overseas_balance)
# 캐시를 강제로 비워 이번 호출에서 반드시 재동기화되게 함
portfolio_module.portfolio_cache.holdings = []
portfolio_module.portfolio_cache.last_synced = None
portfolio_module.portfolio_cache.mark_changed()
try:
r_legacy = await client.get("/api/v1/portfolio", headers=_auth(_get_api_key()))
assert r_legacy.status_code == 200, r_legacy.text
legacy_rows = sorted(r_legacy.json(), key=lambda h: h["stock_code"])
r_v2 = await client.get("/api/v1/portfolio/v2", headers=_auth(_get_api_key()))
assert r_v2.status_code == 200, r_v2.text
v2_rows = sorted(r_v2.json(), key=lambda h: h["stock_code"])
assert [h["stock_code"] for h in legacy_rows] == [h["stock_code"] for h in v2_rows]
assert [h["quantity"] for h in legacy_rows] == [h["quantity"] for h in v2_rows]
finally:
async with AsyncSessionLocal() as db:
await db.execute(delete(Portfolio).where(Portfolio.stock_code == stock_code))
await db.execute(delete(User).where(User.id == legacy_admin_id))
await db.commit()
portfolio_module.portfolio_cache.holdings = []
portfolio_module.portfolio_cache.mark_changed()
await user_context_registry.invalidate_all()
# ─────────────────────────────────────────────────────────────────────────────
# (f) GET /portfolio/v2 — 사용자별 격리 + 관리자 ?user_id= 열람 + 비관리자 403
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_f_portfolio_v2_isolation_and_admin_override(client, monkeypatch, two_users):
from app.core.config import settings
monkeypatch.setattr(settings, "MULTI_TENANT_ENABLED", True)
async with AsyncSessionLocal() as db:
db.add(Portfolio(
user_id=two_users["a_id"], stock_code="000660", stock_name="A종목",
quantity=5, average_price=1000, current_price=1000, profit_loss=0, profit_loss_rate=0,
market="KRX", strategy_type="manual",
))
db.add(Portfolio(
user_id=two_users["b_id"], stock_code="035420", stock_name="B종목",
quantity=7, average_price=2000, current_price=2000, profit_loss=0, profit_loss_rate=0,
market="KRX", strategy_type="manual",
))
await db.commit()
try:
r = await client.get("/api/v1/portfolio/v2", headers=_auth(two_users["a_token"]))
assert r.status_code == 200
codes_a = {h["stock_code"] for h in r.json()}
assert "000660" in codes_a
assert "035420" not in codes_a # B의 행이 보이지 않음
r = await client.get("/api/v1/portfolio/v2", headers=_auth(two_users["b_token"]))
codes_b = {h["stock_code"] for h in r.json()}
assert "035420" in codes_b
assert "000660" not in codes_b
# 관리자(A)가 ?user_id=B 로 조회 — 허용
r = await client.get(
f"/api/v1/portfolio/v2?user_id={two_users['b_id']}", headers=_auth(two_users["a_token"])
)
assert r.status_code == 200
assert "035420" in {h["stock_code"] for h in r.json()}
# 비관리자(B)가 ?user_id=A 로 조회 — 403
r = await client.get(
f"/api/v1/portfolio/v2?user_id={two_users['a_id']}", headers=_auth(two_users["b_token"])
)
assert r.status_code == 403
finally:
async with AsyncSessionLocal() as db:
await db.execute(delete(Portfolio).where(Portfolio.stock_code.in_(["000660", "035420"])))
await db.commit()
# ─────────────────────────────────────────────────────────────────────────────
# (g) POST /portfolio/v2/sell — 타 사용자 종목 404, 본인 종목은 ctx.user_id로 주문·close 호출
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_g_sell_v2_cross_user_404_and_own_stock_uses_ctx(client, monkeypatch, two_users):
from app.core.config import settings
from app.services.tenant import registry as registry_module
monkeypatch.setattr(settings, "MULTI_TENANT_ENABLED", True)
fake_kis_a = _FakeKis()
ctx_a = UserContext(
user_id=two_users["a_id"], username="a", mode="virtual", kis=fake_kis_a,
mdd=None, loss_tracker=None, auto_trade_enabled=True, is_legacy=False,
)
async def _fake_resolve(current_user, db):
return ctx_a
monkeypatch.setattr(registry_module.user_context_registry, "resolve", _fake_resolve)
close_calls = []
async def _fake_close(user_id, stock_code, strategy_name=None):
close_calls.append((user_id, stock_code, strategy_name))
monkeypatch.setattr(
"app.api.endpoints.portfolio_v2.enqueue_close_active_trade_v2", _fake_close
)
async with AsyncSessionLocal() as db:
db.add(Portfolio(
user_id=two_users["a_id"], stock_code="005380", stock_name="A보유",
quantity=10, average_price=1000, current_price=1000, profit_loss=0, profit_loss_rate=0,
market="KRX", strategy_type="manual",
))
db.add(Portfolio(
user_id=two_users["b_id"], stock_code="051910", stock_name="B보유",
quantity=10, average_price=1000, current_price=1000, profit_loss=0, profit_loss_rate=0,
market="KRX", strategy_type="manual",
))
await db.commit()
try:
# B 종목을 A가 매도 시도 → 404
r = await client.post(
"/api/v1/portfolio/v2/sell",
headers=_auth(two_users["a_token"]),
json={"stock_code": "051910", "quantity": 1},
)
assert r.status_code == 404
# A 종목 매도 → 성공, fake kis 호출 인자에 ctx.user_id 반영 확인
r = await client.post(
"/api/v1/portfolio/v2/sell",
headers=_auth(two_users["a_token"]),
json={"stock_code": "005380", "quantity": 10},
)
assert r.status_code == 200, r.text
assert fake_kis_a.place_order_calls, "safe_place_order_v2가 ctx.kis(fake)를 통해 주문해야 한다"
call = fake_kis_a.place_order_calls[0]
assert call["stock_code"] == "005380" and call["order_type"] == "SELL"
assert close_calls and close_calls[0][0] == two_users["a_id"] and close_calls[0][1] == "005380"
finally:
async with AsyncSessionLocal() as db:
await db.execute(delete(Portfolio).where(Portfolio.stock_code.in_(["005380", "051910"])))
await db.commit()
# ─────────────────────────────────────────────────────────────────────────────
# (h) ensure_bootstrap_admin() — 빈 users, KIS env 이관, idempotent, NULL 백필
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_h_bootstrap_admin_seed_and_idempotent(monkeypatch):
from app.core.config import settings
# 이 테스트만을 위한 격리 — users 테이블을 비워야 하므로 세션 스코프 오염 방지를 위해
# 테스트 전용 사용자명으로 필터링된 범위만 조작 가능해야 하나, ensure_bootstrap_admin은
# "users 테이블 전체가 비어있는지"를 보므로 실제로 테이블을 비운다 — 임시 DB 한정.
async with AsyncSessionLocal() as db:
existing = (await db.execute(select_all_users())).scalars().all()
assert existing == [], (
"이 테스트는 users 테이블이 완전히 비어 있어야 한다 — 임시 격리 DB 전용 테스트. "
"다른 테스트와 병렬/순서 오염 시 실패할 수 있음 (세션 순차 실행 전제)."
)
monkeypatch.setattr(settings, "BOOTSTRAP_ADMIN_PASSWORD", "bootstrap-pw-123")
monkeypatch.setattr(settings, "BOOTSTRAP_ADMIN_USERNAME", f"bootadmin_{_SUFFIX}")
monkeypatch.setattr(settings, "CREDENTIALS_ENCRYPTION_KEY", "x" * 40)
monkeypatch.setattr(settings, "KOREA_INVESTMENT_APP_KEY", f"envappkey{_SUFFIX}")
monkeypatch.setattr(settings, "KOREA_INVESTMENT_APP_SECRET", "envsecret")
monkeypatch.setattr(settings, "KOREA_INVESTMENT_ACCOUNT_NUMBER", "1234567801")
monkeypatch.setattr(settings, "KIS_MODE", "virtual")
monkeypatch.setattr(settings, "AUTO_TRADE_ENABLED", False)
try:
await tenant_bootstrap.ensure_bootstrap_admin()
async with AsyncSessionLocal() as db:
from sqlalchemy import select as _select
admin = (
await db.execute(_select(User).where(User.username == f"bootadmin_{_SUFFIX}"))
).scalar_one_or_none()
assert admin is not None
assert admin.is_admin is True and admin.is_active is True
cred = (
await db.execute(_select(UserKisCredential).where(UserKisCredential.user_id == admin.id))
).scalar_one_or_none()
assert cred is not None
from app.core.crypto import decrypt
assert decrypt(cred.app_key_enc, cred.key_version) == f"envappkey{_SUFFIX}"
assert decrypt(cred.account_no_enc, cred.key_version) == "1234567801"
# NULL user_id 백필 확인용 레거시 행 삽입 후 재실행
async with AsyncSessionLocal() as db:
db.add(Portfolio(
user_id=None, stock_code="900001", stock_name="레거시", quantity=1,
average_price=100, current_price=100, market="KRX", strategy_type="swing",
))
await db.commit()
await tenant_bootstrap.ensure_bootstrap_admin() # 2번째 호출 — idempotent 여야 함
async with AsyncSessionLocal() as db:
from sqlalchemy import select as _select, func as _func
admin_count = (
await db.execute(
_select(_func.count()).select_from(User).where(User.username == f"bootadmin_{_SUFFIX}")
)
).scalar()
assert admin_count == 1, "재호출해도 관리자 계정이 중복 생성되지 않아야 한다"
cred_count = (
await db.execute(
_select(_func.count()).select_from(UserKisCredential).where(UserKisCredential.user_id == admin.id)
)
).scalar()
assert cred_count == 1, "재호출해도 KIS 자격증명이 중복 이관되지 않아야 한다"
legacy_row = (
await db.execute(_select(Portfolio).where(Portfolio.stock_code == "900001"))
).scalar_one_or_none()
assert legacy_row is not None and legacy_row.user_id == admin.id, (
"user_id IS NULL 이던 레거시 행이 owner로 백필되어야 한다"
)
finally:
async with AsyncSessionLocal() as db:
from sqlalchemy import select as _select
admin = (
await db.execute(_select(User).where(User.username == f"bootadmin_{_SUFFIX}"))
).scalar_one_or_none()
await db.execute(delete(Portfolio).where(Portfolio.stock_code == "900001"))
if admin is not None:
await db.execute(delete(UserKisCredential).where(UserKisCredential.user_id == admin.id))
await db.execute(delete(User).where(User.id == admin.id))
await db.commit()
def select_all_users():
from sqlalchemy import select as _select
return _select(User)
# ─────────────────────────────────────────────────────────────────────────────
# M1 회귀 방지 — CurrentUser.effective_user_id 와 UserContextRegistry.legacy().user_id 가
# 관리자 미존재 상태에서 모두 None 으로 일치해야 한다(수정 전에는 0 vs None 불일치 결함이었음).
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_m1_effective_user_id_matches_legacy_owner_id_when_no_admin():
"""관리자 0명 상태에서 `CurrentUser.effective_user_id`와
`UserContextRegistry.legacy().user_id`가 모두 `None`으로 일치해야 한다(M1).
수정 전에는 후자가 `users.id=0`(존재하지 않는 FK 위반 값)을 반환해
`enqueue_trade_history({"user_id": 0})` → FK 위반 → db_worker 재시도 후 폐기로
이어졌다.
"""
from app.core.tenant_auth import CurrentUser
async with AsyncSessionLocal() as db:
no_admin = (
await db.execute(select_all_users())
).scalars().all()
# 관리자 없는 상태를 흉내내기 위해 admin 여부만 확인 (실제 삭제는 하지 않음 — 다른 테스트와 공유 DB)
has_admin = any(u.is_admin for u in no_admin)
current_user = CurrentUser(id=None, username="api-key", is_admin=True, is_api_key=True, _legacy_owner_id=None)
assert current_user.effective_user_id is None
if not has_admin:
# legacy 캐시가 이전 테스트에서 owner_id로 확정(캐시)돼 있을 수 있으므로 리셋
user_context_registry._legacy_ctx = None
user_context_registry._legacy_checked_at = 0.0
user_context_registry._legacy_owner_confirmed = False
legacy_ctx = await user_context_registry.legacy(None)
assert legacy_ctx.user_id is None
assert current_user.effective_user_id == legacy_ctx.user_id
# ─────────────────────────────────────────────────────────────────────────────
# H2 — 비활성(회수) 세션 사용자는 legacy owner 로 폴백하지 않고 401
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_h2_inactive_session_user_returns_401_not_legacy_fallback(client):
async with AsyncSessionLocal() as db:
u = User(username=f"mt_revoked_{_SUFFIX}", password_hash="x", is_active=False, is_admin=False)
db.add(u)
await db.commit()
await db.refresh(u)
token, _ = create_session_token(u.username)
try:
r = await client.get("/api/v1/account/credentials", headers=_auth(token))
assert r.status_code == 401, r.text
r = await client.get("/api/v1/portfolio/v2", headers=_auth(token))
assert r.status_code == 401, r.text
finally:
async with AsyncSessionLocal() as db:
await db.execute(delete(User).where(User.id == u.id))
await db.commit()
# ─────────────────────────────────────────────────────────────────────────────
# H3 — 자격증명 PUT/DELETE/PATCH 후 registry 캐시가 무효화되어야 함
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_h3_credential_endpoints_invalidate_registry_cache(client, two_users):
user_id = two_users["a_id"]
headers = _auth(two_users["a_token"])
body = {"mode": "virtual", "app_key": f"h3key{_SUFFIX}", "app_secret": "s", "account_no": "1234567801"}
r = await client.put("/api/v1/account/credentials", headers=headers, json=body)
assert r.status_code == 200, r.text
dummy_ctx = UserContext(
user_id=user_id, username="stale", mode="virtual", kis=_FakeKis(),
mdd=None, loss_tracker=None, auto_trade_enabled=True, is_legacy=False,
)
# PUT은 이미 위에서 한 번 invalidate를 호출했으므로, 이후 더미를 주입해 재확인
user_context_registry._contexts[user_id] = dummy_ctx
assert user_context_registry._contexts.get(user_id) is dummy_ctx
r = await client.patch("/api/v1/account/auto-trade", headers=headers, json={"enabled": False})
assert r.status_code == 200, r.text
assert user_id not in user_context_registry._contexts, (
"PATCH /auto-trade 이후 캐시된 UserContext가 무효화되어야 한다(H3)"
)
user_context_registry._contexts[user_id] = dummy_ctx
r = await client.delete("/api/v1/account/credentials", headers=headers)
assert r.status_code == 204, r.text
assert user_id not in user_context_registry._contexts, (
"DELETE /credentials 이후 캐시된 UserContext가 무효화되어야 한다(H3)"
)
# ─────────────────────────────────────────────────────────────────────────────
# M1 — owner 없는 API 키(legacy ctx user_id=None) 매수 시 db_worker payload에 user_id 키가
# 없어야 한다(0을 넣어 FK 위반을 내던 구 결함 방지, 기존 NULL 행과 정합).
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_m1_legacy_ctx_no_owner_buy_payload_omits_user_id(client, monkeypatch):
from app.core.config import settings
from app.services.tenant import registry as registry_module
monkeypatch.setattr(settings, "MAX_HOLDINGS_COUNT", 10_000) # 공유 DB에 쌓인 NULL 행과 무관하게
fake_kis = _FakeKis()
ctx = UserContext(
user_id=None, username="legacy", mode="virtual", kis=fake_kis,
mdd=_FakeMddAlwaysOpen(), loss_tracker=_FakeLossTrackerNeverBanned(),
auto_trade_enabled=True, is_legacy=True,
)
async def _fake_resolve(current_user, db):
return ctx
monkeypatch.setattr(registry_module.user_context_registry, "resolve", _fake_resolve)
captured_history: dict = {}
captured_portfolio: dict = {}
async def _fake_enqueue_trade_history(data):
captured_history.update(data)
async def _fake_enqueue_portfolio_upsert(data):
captured_portfolio.update(data)
monkeypatch.setattr("app.api.endpoints.portfolio_v2.enqueue_trade_history", _fake_enqueue_trade_history)
monkeypatch.setattr("app.api.endpoints.portfolio_v2.enqueue_portfolio_upsert", _fake_enqueue_portfolio_upsert)
stock_code = f"6{_SUFFIX[-5:]}"
try:
r = await client.post(
"/api/v1/portfolio/v2/buy",
headers=_auth(_get_api_key()),
json={"stock_code": stock_code, "quantity": 1},
)
assert r.status_code == 200, r.text
assert fake_kis.place_order_calls, "safe_place_order_v2가 ctx.kis(fake)를 통해 주문해야 한다"
assert "user_id" not in captured_history, "legacy ctx(owner 없음)는 trade_history payload에 user_id를 넣지 않아야 한다"
assert "user_id" not in captured_portfolio, "legacy ctx(owner 없음)는 portfolio upsert payload에 user_id를 넣지 않아야 한다"
finally:
async with AsyncSessionLocal() as db:
await db.execute(delete(Portfolio).where(Portfolio.stock_code == stock_code))
await db.execute(delete(TradeHistory).where(TradeHistory.stock_code == stock_code))
await db.commit()
# ─────────────────────────────────────────────────────────────────────────────
# M3 — legacy ctx의 전량 매도는 enqueue_close_active_trade_v2가 아니라 기존
# enqueue_close_active_trade(user_id 없음)를 호출해야 한다(ActiveTrade.user_id==owner
# 필터로 NULL 행을 못 찾아 유령 포지션이 남는 결함 방지).
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_m3_legacy_sell_full_exit_uses_v1_close_not_v2(client, monkeypatch):
from app.services.tenant import registry as registry_module
fake_kis = _FakeKis()
ctx = UserContext(
user_id=None, username="legacy", mode="virtual", kis=fake_kis,
mdd=_FakeMddAlwaysOpen(), loss_tracker=_FakeLossTrackerNeverBanned(),
auto_trade_enabled=True, is_legacy=True,
)
async def _fake_resolve(current_user, db):
return ctx
monkeypatch.setattr(registry_module.user_context_registry, "resolve", _fake_resolve)
v1_calls = []
v2_calls = []
async def _fake_close_v1(stock_code, strategy_name=None):
v1_calls.append((stock_code, strategy_name))
async def _fake_close_v2(user_id, stock_code, strategy_name=None):
v2_calls.append((user_id, stock_code, strategy_name))
monkeypatch.setattr("app.api.endpoints.portfolio_v2.enqueue_close_active_trade", _fake_close_v1)
monkeypatch.setattr("app.api.endpoints.portfolio_v2.enqueue_close_active_trade_v2", _fake_close_v2)
stock_code = f"5{_SUFFIX[-5:]}"
async with AsyncSessionLocal() as db:
db.add(Portfolio(
user_id=None, stock_code=stock_code, stock_name="레거시보유", quantity=10,
average_price=1000, current_price=1000, profit_loss=0, profit_loss_rate=0,
market="KRX", strategy_type="manual",
))
await db.commit()
try:
r = await client.post(
"/api/v1/portfolio/v2/sell",
headers=_auth(_get_api_key()),
json={"stock_code": stock_code, "quantity": 10},
)
assert r.status_code == 200, r.text
assert v1_calls and v1_calls[0][0] == stock_code
assert not v2_calls, "legacy ctx는 enqueue_close_active_trade_v2가 아니라 v1(close_active_trade)을 호출해야 한다"
finally:
async with AsyncSessionLocal() as db:
await db.execute(delete(Portfolio).where(Portfolio.stock_code == stock_code))
await db.commit()
# ─────────────────────────────────────────────────────────────────────────────
# M5 — MULTI_TENANT_ENABLED=True 에서 legacy owner가 아닌 세션 사용자는 legacy
# `/portfolio` 라우터 접근이 403이어야 한다(`deny_non_owner_session_when_mt`).
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_m5_legacy_portfolio_denied_for_non_owner_session_when_mt(client, monkeypatch, two_users):
from app.core.config import settings
monkeypatch.setattr(settings, "MULTI_TENANT_ENABLED", True)
# two_users["b"]는 non-admin(legacy owner일 수 없음)
r = await client.get("/api/v1/portfolio", headers=_auth(two_users["b_token"]))
assert r.status_code == 403, r.text
r = await client.get("/api/v1/portfolio/v2", headers=_auth(two_users["b_token"]))
assert r.status_code != 403, "/portfolio/v2 는 deny_non_owner_session_when_mt 대상이 아니다"