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

@@ -24,6 +24,13 @@ SIGNUP_ENABLED=true
SIGNUP_AUTO_APPROVE=false
SESSION_TOKEN_EXPIRE_HOURS=24
# 멀티테넌트(사용자별 매매) 1단계 — 기본 비활성. True 전환 전 §docs/multi_tenant_design.md 이행 순서 확인
MULTI_TENANT_ENABLED=false
CREDENTIALS_ENCRYPTION_KEY= # user_kis_credentials 암복호화 키 소스. 비우면 SECRET_KEY 폴백(SECRET_KEY 32자 이상 필수)
CREDENTIALS_ENCRYPTION_KEY_PREVIOUS= # 키 로테이션 중에만 설정(구 키, 복호화 전용)
BOOTSTRAP_ADMIN_USERNAME=admin # 앱 최초 기동 시 users 테이블이 비어있으면 시드할 관리자 계정명
BOOTSTRAP_ADMIN_PASSWORD= # 비우면 부트스트랩 관리자 시드를 건너뜀
# ═══════════════════════════════════════════════════════════════════════════════
# 데이터베이스 및 캐시
# ═══════════════════════════════════════════════════════════════════════════════

View File

@@ -8,6 +8,7 @@
- **인프라·ML**: Docker Compose, Nginx, Alembic / PyTorch, LightGBM, scikit-learn
- **연동**: 한국투자증권(KIS) REST·WebSocket, MCP 서버(stdio) / AI 계층: `AI_PROVIDER`=claude|gemini
- **주요 .env**: `KIS_MODE`(virtual|real), `TOKEN_SAVE_MODE`(JSON 압축+차트 30일 제한), `AUTO_TRADE_ENABLED`, `US_TRADING_ENABLED`
- **배포**: 코드 수정 후 `docker compose up -d --build`(옛 이미지 재사용 방지) — **스키마(모델) 변경 시 반드시 `alembic upgrade head`를 이미지 기동보다 먼저 실행**(`create_all`은 기존 테이블에 컬럼을 추가하지 않음, H1).
## 2. 구조 (`backend/app/`)
- `api/endpoints/` — 20개 모듈 (portfolio, market_data, trades, day_trading, backtest 등)
@@ -22,7 +23,7 @@
## 3. 아키텍처 원칙 (필수)
1. **실시간 계층 DB I/O 절대 금지** — API 콜백·웹소켓·실시간 스케줄러에서 `AsyncSessionLocal()`/`commit()` 호출 금지.
2. **모든 DB 쓰기는 `db_worker.py`의 `db_task_queue` 경유** (실패 시 지수 백오프 1→2→4초, 최대 3회 재시도). 예외: `sync_portfolio()`만 단일 트랜잭션 필요로 엔드포인트 직접 DB 접근 허용.
2. **모든 DB 쓰기는 `db_worker.py`의 `db_task_queue` 경유** (실패 시 지수 백오프 1→2→4초, 최대 3회 재시도). 예외: `sync_portfolio()`만 단일 트랜잭션 필요로 엔드포인트 직접 DB 접근 허용. auth/credentials/bootstrap 시드(회원가입·로그인, `user_kis_credentials` 등록/삭제, 앱 기동 시 관리자·자격증명 시드)도 단일 트랜잭션 직접 접근 허용 — 실시간 계층(콜백·스케줄러) 아님.
3. 지원 task: insert_trade_history, update_trade_history(+_by_order_id), upsert_stock_analysis, bulk_upsert_stock_analysis, upsert_stock_ban, upsert_portfolio, upsert_active_trade, close_active_trade, insert_trade_journal, log_mcp_deny
4. **동시성**: trade_executor `_order_semaphores`(종목별 동시 주문 방지) / Redis 분산 락(워커 간 동시 매도 방지) / loss_tracker·mdd_manager `asyncio.Lock`. 동시 접근 경로에서는 `is_banned_safe()`, `record_trade_result_safe()` 사용.

33
Jenkinsfile vendored
View File

@@ -73,6 +73,30 @@ print('All files OK')
}
}
// H1: 마이그레이션은 반드시 Deploy(백엔드 재기동) 이전에 끝나야 한다.
// `create_all()`은 신규 테이블만 생성하고 기존 테이블 컬럼 추가는 하지 않으므로,
// 새 이미지를 먼저 올리면(구 스키마 + 신규 컬럼을 매핑하는 신규 코드) 레거시 경로
// 전체가 UndefinedColumnError로 실패한다. 마이그레이션 실패 시 파이프라인을 중단한다
// (기존 backend 컨테이너는 아직 중지 전이라 서비스 영향 없음).
stage('DB Migration') {
steps {
sh """
cd ${DEPLOY_DIR}
# N1: create_all 전용으로 운영돼 alembic_version 이 비어 있으면 upgrade head 가
# 0001 부터 실행돼 기존 컬럼과 충돌한다. 이력이 없으면 현 스키마(users 테이블까지
# create_all 로 존재) 기준인 0008 로 stamp 한 뒤 0009+ 만 적용한다.
CURRENT=\$(docker-compose run --rm --no-deps backend alembic current 2>/dev/null | grep -oE '^[0-9a-f]{4,}' || true)
if [ -z "\$CURRENT" ]; then
echo "alembic 이력 없음 → stamp 0008"
docker-compose run --rm --no-deps backend alembic stamp 0008
else
echo "alembic current: \$CURRENT"
fi
docker-compose run --rm --no-deps backend alembic upgrade head
"""
}
}
stage('Deploy') {
steps {
sh """
@@ -98,15 +122,6 @@ print('All files OK')
"""
}
}
stage('DB Migration') {
steps {
sh """
cd ${DEPLOY_DIR}
docker-compose exec -T backend alembic upgrade head || echo "WARNING: Migration failed!"
"""
}
}
}
post {

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 대상이 아니다"

269
docs/multi_tenant_design.md Normal file
View File

@@ -0,0 +1,269 @@
# 멀티테넌트(사용자별 매매) 전환 설계
작성: 2026-09-20 / 상태: 설계(코드 미수정) / 대상 브랜치: `fix/trading-adx-warmup-mlgate` 이후 신규 브랜치 `feat/multi-tenant-phase1`
---
## 1. 현황 요약 & 제약
### 1.1 이미 있는 것
| 항목 | 위치 | 비고 |
|---|---|---|
| `User` 모델 (id, username, password_hash, is_active, is_admin) | `backend/app/models/models.py:390-405`, alembic `0008_add_users_table.py` | 첫 가입자 = 관리자 |
| 로그인/세션 토큰 | `backend/app/api/endpoints/auth.py` (register/login/me/users), `app/core/security.py:49-62` (`create_session_token`, `verify_session_token` → username) | JWT HS256, `sub=username`, aud=`autostock-dashboard`, 24h |
| 인증 의존성 | `security.py:74` `require_api_key` — X-API-Key 헤더에 **API 키(SECRET_KEY/API_KEY) 또는 세션 토큰** 모두 수용 | 어느 쪽인지 구분은 `auth.py:147 _resolve_user`, `auth.py:155 require_admin`에서만 수행 |
| 라우터 등록 | `backend/app/api/__init__.py:23-95` — 라우터 단위 `dependencies=[Depends(require_api_key)]` | 엔드포인트 함수는 사용자를 모름 |
### 1.2 단일 계좌 전제인 지점 (근거)
| 구성요소 | 근거 | 단일 전제 내용 |
|---|---|---|
| KIS 클라이언트 | `korea_investment_api.py:219-261 __init__`, `:1315 korea_investment_api = KoreaInvestmentAPI()`, `:1320 real_market_api` | `settings.KOREA_INVESTMENT_APP_KEY/SECRET/ACCOUNT_NUMBER`(config.py:389-392에서 KIS_MODE로 결정)만 사용. 모듈 import 시 즉시 인스턴스화 |
| 토큰 캐시 | `:178 TOKEN_CACHE_FILE = ~/.cache/auto-stock/kis_token_cache.json`, `:201 kis_token_cache_real.json` | 파일명 고정 → 사용자별 인스턴스가 서로 덮어씀 |
| 레이트리미터 | `:229 _throttle_lock`, `:232 min_request_interval=settings.API_MIN_REQUEST_INTERVAL(0.15)`, `:239 semaphore(API_MAX_CONCURRENT_REQUESTS=8)`, `:406 _get_access_token`(_token_lock, `_handle_rate_limit` 1분 제한) | 인스턴스 단위 상태 → 인스턴스 = 앱키 단위로 만들면 자연히 앱키별 제한이 됨 |
| 시세 위임 | `:558 _quote_delegate` → `globals().get("real_market_api")`; `KIS_QUOTES_VIA_REAL`(config.py:78) | 조회 API는 계좌와 무관(앱키만 필요). 공용 1개로 공유 가능 |
| 테이블 | `models.py` Portfolio(`:158 stock_code unique`), TradeHistory, ActiveTrade, StockBan(`:336 PK=stock_code`), TradeJournal, TradingSession(`:45 session_date unique`), WatchlistStock/History | user_id 컬럼 없음 |
| db_worker | `db_worker.py:264 db_writer_worker` — `upsert_portfolio`(:416 stock_code로 SELECT), `upsert_active_trade`(:441 stock_code+status+strategy_name), `close_active_trade`(:483), `upsert_stock_ban`(:351 stock_code) | 조회 키에 user_id 없음 |
| trade_executor | `:21 from ... import korea_investment_api`(모듈 전역), `:45 _order_semaphores{stock_code}`, `:95 order_lock:{stock_code}:{order_type}`(Redis), `:169 safe_place_order(stock_code, order_type, quantity, price, is_domestic, exchange_code)`, `:554 get_balance()`, `:1438 mdd_manager.can_open_new_position()`, `:1442 loss_tracker.is_banned_safe()` | 전부 전역 싱글톤 참조 |
| mdd_manager | `mdd_manager.py:21-28` peak_balance/is_paused/… + `_lock`, `:15 _MDD_REDIS_KEY="mdd:state"`, `:325 mdd_manager = MDDManager()`, `:200 execute_reduction`은 `korea_investment_api.get_balance()` 직접 호출 | 단일 상태·단일 Redis 키 |
| loss_tracker | `loss_tracker.py:27 _tracker{stock_code}`, `:53 save_to_db(db=None, stock_code)` → `enqueue_stock_ban_upsert(stock_code, …)`, `:79 load_from_db(db)` 전체 StockBan 로드, `:171 loss_tracker = _create_loss_tracker()` | 단일 dict |
| 스케줄러 | `swing/scheduler.py:79 swing_scheduler()` 60s 루프(`:119 korea_investment_api.get_balance()`, `:107/167/243/249 AsyncSessionLocal()`), `daytrading/scheduler.py:46 daytrading_scheduler()` 30s(`:32 day_trading_state` 싱글톤), `main.py:239-240 create_task` | 프로세스당 루프 1개 |
| 데이트레이딩 상태 | `daytrading/position_manager.py:48 DayTradingState` 싱글톤(positions, orb_cache, daily_pnl, loss_streak_today), `daytrading/entry_service.py:69-80 set/get_watchlist` 모듈 전역 | 단일 |
| 포트폴리오 캐시 | `portfolio.py:199 PortfolioCache`(holdings, summary, sync_lock) 프로세스 단일 인스턴스, `/portfolio/sync`(:453)가 Portfolio 테이블 직접 커밋(:734-738, CLAUDE.md 허용 예외) | 단일 |
| WebSocket | `websocket_manager.py:93 broadcast`/`:105 broadcast_json` → 모든 연결; `main.py:614-618` 토큰 검증만(`is_valid_credential`) 후 `client_id` 등록. 호출지: `trade_executor.py:916,1157`, `portfolio.py:1265` | 사용자 개념 없음 |
| MCP/외부 호출자 | `mcp-server/trade_monitor.py:34-35` `X-API-Key = API_KEY or SECRET_KEY`, `:58 /api/v1/portfolio/active-trades/{code}/status` | API 키 호출 = 사용자 미지정 |
| 프론트 | `frontend/src/lib/api.ts:65-67` `localStorage.api_key` → X-API-Key; `useWebSocket.ts:83` `?token=`; `App.tsx:109-116` 라우트 6개; `Layout.tsx:62-68` navItems | 계좌 설정 화면 없음 |
### 1.3 지켜야 할 규칙 (CLAUDE.md)
- 기존 함수/클래스 시그니처 변경 금지 → 새 함수는 `_v2`, 새 모듈은 새 파일. 기존 전역 싱글톤(`korea_investment_api`, `mdd_manager`, `loss_tracker`, `db_task_queue`)은 그대로 두고 **legacy owner 사용자의 컨텍스트 별칭**으로 유지.
- DB 쓰기는 `db_worker` 경유(예외: `sync_portfolio` 류 단일 트랜잭션 필요 엔드포인트). 실시간 계층 DB I/O 금지.
- 리스크 규칙(§5)은 **사용자별 자본 기준**으로 동일 적용. SL 선택 `min()`. 전략 로직 자체는 변경하지 않음(백테스트 불필요 — §8 참고).
- `_dumps()` 사용, 쓰기 API `require_api_key`.
---
## 2. 목표 아키텍처
```
┌──────────────────────────────────────────┐
HTTP/WS 요청 ───▶ │ get_current_user (세션토큰→User / API키→legacy owner)│
└──────────────┬───────────────────────────┘
▼
┌──────────────────────────────────────────┐
│ UserContextRegistry (app/services/tenant/registry.py)│
│ get(user_id) -> UserContext (캐시, TTL/무효화) │
│ iter_active() -> 자동매매 활성 사용자 컨텍스트 목록 │
└──────────────┬───────────────────────────┘
▼
UserContext(user_id, username, kis: KoreaInvestmentAPI(주문·잔고 전용),
mode: virtual|real, mdd: MDDManager, loss_tracker: ConsecutiveLossTracker,
dt_state: DayTradingState, portfolio_cache: PortfolioCache, settings_override)
│
┌──────────────────────────┼─────────────────────────────┐
▼ ▼ ▼
trade_executor_v2(ctx, …) swing/daytrading 스케줄러 db_worker task(data.user_id)
safe_place_order_v2(ctx,…) 사용자별 asyncio.Task upsert_* 핸들러 user 필터
│
▼
공용 시세 인스턴스 real_market_api (앱키 1개, 20/s) ← 모든 사용자 공유 (조회 전용)
```
### 2.1 핵심 결정
1. **시세(조회) = 공용 1개, 주문·잔고 = 사용자별 인스턴스.** `KoreaInvestmentAPI`의 조회 메서드는 이미 `_quote_delegate()`로 `real_market_api`에 위임하는 구조(`korea_investment_api.py:558-598, 794, 855, 1273`)이며 조회 API는 계좌번호를 사용하지 않는다(`get_current_price` params에 CANO 없음). 따라서 사용자별 인스턴스에는 `_quote_delegate`가 공용 `real_market_api`를 가리키도록 하고, 사용자 앱키는 `place_order`/`get_balance`/`get_order_execution`/`cancel_order`/해외 주문·잔고에만 소비된다. → **사용자 수가 늘어도 시세 트래픽은 앱키 1개(20/s)에 고정**, 사용자별 앱키는 주문·잔고(모의 2/s)만 감당. 단 `KIS_QUOTES_VIA_REAL=False`이거나 운영자가 실전 앱키를 등록하지 않았으면 각 사용자 인스턴스가 자기 앱키로 조회한다(현재 동작과 동일).
2. **legacy owner**: `users` 중 `is_admin=True`이고 `id`가 가장 작은 사용자. `.env`의 KIS 자격증명은 이 사용자의 `user_kis_credentials` 행으로 이관(관리자 API). `MULTI_TENANT_ENABLED=False`(기본)이면 현재 전역 싱글톤이 그대로 legacy owner 컨텍스트로 동작.
3. **기존 싱글톤 유지**: `korea_investment_api`, `mdd_manager`, `loss_tracker`, `day_trading_state`, `portfolio_cache`는 삭제하지 않고 `UserContextRegistry.legacy()`가 이 인스턴스들을 감싼 컨텍스트를 반환. 기존 호출 코드는 수정 없이 동작.
4. **Redis 키·락 네임스페이스에 user_id 포함**: `order_lock:{user_id}:{stock_code}:{order_type}`, `mdd:state:{user_id}`. legacy 컨텍스트(user_id=None 또는 legacy owner id)는 기존 키를 그대로 사용해 하위 호환.
5. **db_worker는 새 task 타입을 추가하지 않고 payload `data["user_id"]` 옵션으로 확장**(§3.4 근거).
---
## 3. 데이터 모델 변경
### 3.1 신규 테이블 `user_kis_credentials` (models.py에 `UserKisCredential` 추가)
| 컬럼 | 타입 | 비고 |
|---|---|---|
| id | Integer PK | |
| user_id | Integer FK users.id ON DELETE CASCADE, **unique** | 사용자당 1세트(모의/실전 동시 등록은 2단계에서 `UniqueConstraint(user_id, mode)`로 완화 가능 — 1단계는 1세트) |
| mode | String(10) NOT NULL | `virtual` \| `real` |
| app_key_enc | Text NOT NULL | Fernet 암호문 |
| app_secret_enc | Text NOT NULL | Fernet 암호문 |
| account_no_enc | Text NOT NULL | 계좌 8자리+상품코드 2자리 (`korea_investment_api.py:1006-1007`이 `[:8]`,`[8:]`로 분리하므로 하이픈 제거 후 10자리 저장) |
| account_product_code | String(2) NOT NULL default '01' | 검색·표시용 (평문 허용) |
| app_key_hint | String(8) | 마스킹 표시용 앞 6자 (`korea_investment_api.py:257`와 동일 마스킹 관례) |
| is_enabled | Boolean default False | 자동매매 대상 여부 (등록 직후 False → 연결 테스트 성공 시 True 허용) |
| auto_trade_enabled | Boolean default False | 사용자 단위 AUTO_TRADE_ENABLED |
| last_verified_at | DateTime | 연결 테스트 성공 시각 |
| key_version | Integer default 1 | 암호화 키 로테이션용 (§9) |
| created_at / updated_at | DateTime | |
### 3.2 user_id 추가 대상 (사용자 스코프)
| 테이블 | 현 제약 | 변경 | 근거 |
|---|---|---|---|
| `portfolio` | `stock_code unique`(models.py:158) | `user_id` 추가; unique → `UniqueConstraint(user_id, stock_code, name=uq_portfolio_user_stock)` | 동일 종목을 여러 사용자가 보유 가능 |
| `trade_history` | 없음 | `user_id` + `Index(user_id, created_at)` | 조회 필터 (`trades.py:51`) |
| `active_trades` | idx stock_code/status(:267,:280) | `user_id` + `Index(user_id, stock_code, status)` | db_worker upsert 키(:445-450)에 user_id 추가 |
| `stock_bans` | `PK=stock_code`(:336) | **PK 변경 불가피** → 새 테이블 `stock_bans_v2 (id PK, user_id, stock_code, consecutive_losses, ban_until, updated_at, UNIQUE(user_id, stock_code))`. 기존 `stock_bans`는 legacy owner용으로 남기고 백필 후 읽기 중단 | PK 변경은 기존 코드 시그니처 변경에 해당 → 새 테이블 |
| `trade_journals` | 없음 | `user_id` | |
| `trading_sessions` | `session_date unique`(:45) | `user_id` + `UniqueConstraint(user_id, session_date)`; 기존 unique 제거 | |
| `watchlist_stocks` / `watchlist_history` | 없음 | **공용 유지**(모닝 배치 산출물, 사용자 무관). 사용자별 감시목록이 필요하면 2단계에서 `user_watchlist` 신규 | |
| `mcp_tool_calls`, `claude_analysis_log` | 없음 | `user_id` nullable 추가(감사 추적용, 필수 아님) | |
**공용 유지(변경 없음)**: `stock_daily_prices`, `stock_analysis`, `market_data`, `news`, `exchange_rates`, `us_stock_universe`, `strategy_configs`, `trading_rules`, `strategy_optimization_logs`, `watchlist_*`, `users`.
### 3.3 Alembic 계획
| rev | 내용 |
|---|---|
| `0009_user_kis_credentials` | `user_kis_credentials` 생성 (+ `ix_user_kis_credentials_user_id` unique) |
| `0010_add_user_id_nullable` | portfolio/trade_history/active_trades/trade_journals/trading_sessions/mcp_tool_calls/claude_analysis_log 에 `user_id Integer NULL FK users.id`; 인덱스 추가; `stock_bans_v2` 생성 |
| `0011_backfill_legacy_owner` | 데이터 마이그레이션: `legacy_owner_id = SELECT id FROM users WHERE is_admin ORDER BY id LIMIT 1`; 없으면 **스킵**(앱 최초 로그인 후 관리자 API `/auth/admin/tenant/migrate-legacy`가 동일 로직 수행). `UPDATE … SET user_id = :owner WHERE user_id IS NULL`; `INSERT INTO stock_bans_v2 SELECT … FROM stock_bans` |
| `0012_user_id_not_null_and_uniques` | `MULTI_TENANT_ENABLED=True` 전환 시점에만 적용: user_id NOT NULL, `portfolio.stock_code` unique 드롭 → `uq_portfolio_user_stock`, `trading_sessions.session_date` unique 드롭 → `(user_id, session_date)` |
0008과 동일하게 `information_schema` 존재 확인 후 idempotent 작성(`0008_add_users_table.py:19-24` 패턴).
### 3.4 db_worker 확장 방식 — 결정: **payload에 `user_id` 옵션 추가** (새 task 타입 아님)
- 근거: 핸들러가 `data.get(...)`로 키를 꺼내는 구조(`db_worker.py:354-356, 419, 444-445, 487-488`)라 `user_id = data.get("user_id")`가 None이면 기존 쿼리, 있으면 `.where(Model.user_id == user_id)` 추가로 **완전 하위 호환**. 새 task 타입(`upsert_portfolio_v2`)은 12개 핸들러 복제·DLQ(`_save_to_dlq`) 분기·`enqueue_*` 헬퍼 중복을 낳아 오히려 위험.
- `enqueue_*` 헬퍼는 시그니처 불변. 사용자 스코프용 새 헬퍼만 추가: `enqueue_stock_ban_upsert_v2(user_id, stock_code, consecutive_losses, ban_until)` (→ `stock_bans_v2` 쓰기, task `upsert_stock_ban_v2` — 이것만 대상 테이블이 다르므로 신규 타입), `enqueue_close_active_trade_v2(user_id, stock_code, strategy_name)`.
- `upsert_portfolio`/`upsert_active_trade`/`close_active_trade`/`insert_trade_history`/`insert_trade_journal`: `data["user_id"]` 있으면 필터·컬럼 세팅. `Portfolio(**data)`(:426)·`ActiveTrade(**create_data)`(:459)는 모델에 컬럼이 생기면 그대로 통과.
---
## 4. 하위 호환 / 이행 전략
- `config.py`에 `MULTI_TENANT_ENABLED: bool = False` 추가.
- **False**: 스케줄러·trade_executor·API 모두 기존 경로. `get_current_user`는 세션 토큰이든 API 키든 **legacy 컨텍스트**를 반환(user_id는 legacy owner id 또는 None). DB 쓰기 payload에 user_id를 넣되(0010 이후 컬럼 존재) 조회 필터는 적용하지 않음 → 기존 동작 100% 유지.
- **True**: `UserContextRegistry`가 `user_kis_credentials.is_enabled=True` 사용자 컨텍스트를 로드, 스케줄러 사용자 순회(2단계), API는 사용자 필터.
- 이관 API(관리자): `POST /api/v1/auth/admin/tenant/migrate-legacy` — `.env`의 `KOREA_INVESTMENT_APP_KEY/SECRET/ACCOUNT_NUMBER` + `KIS_MODE`를 legacy owner의 `user_kis_credentials`로 암호화 저장(이미 있으면 409), 0011 백필 로직 재실행. `require_admin`(auth.py:155) 사용.
- 전환 순서(H1): **`cd backend && alembic upgrade head`로 0009~0011 적용(반드시 새 이미지 기동 전)** → 관리자 로그인 → migrate-legacy → 다른 사용자 자격증명 등록·연결 테스트 → `MULTI_TENANT_ENABLED=True` + 0012 마이그레이션 적용 → 재기동(`docker compose up -d --build`). `create_all()`은 기존 테이블에 컬럼을 추가하지 않으므로 이 순서를 지키지 않으면(새 이미지 먼저 기동) `portfolio.user_id` 등 신규 컬럼 부재로 레거시 경로 전체가 `UndefinedColumnError`로 실패한다 — `main.py` lifespan이 기동 시 이를 감지해 중단시킨다.
- 롤백: `MULTI_TENANT_ENABLED=False`로 되돌리면 legacy 경로. 0012 이전이면 스키마 롤백 불필요.
---
## 5. 1단계 상세 (구현 가능 수준)
### 5.1 자격증명 암복호화 — `backend/app/core/crypto.py` (신규) — **dev-team**
- `cryptography` 패키지: `requirements.txt:15 python-jose[cryptography]`의 extras로 이미 설치됨. 명시적으로 `cryptography>=42` 한 줄 추가 권장(암묵 의존 제거).
- 키 파생: `_derive_key(secret: str, version: int) -> bytes` = `base64.urlsafe_b64encode(hashlib.pbkdf2_hmac("sha256", secret.encode(), f"autostock-kis-cred-v{version}".encode(), 200_000))`. 소스는 `settings.CREDENTIALS_ENCRYPTION_KEY`(신규, 비어 있으면 `settings.SECRET_KEY` 폴백). SECRET_KEY가 기본값 `change-this-secret-key`(config.py:24)면 저장 API 거부.
- `encrypt(plain: str) -> str`, `decrypt(token: str, key_version: int) -> str`. `MultiFernet([current, previous])`로 로테이션: `CREDENTIALS_ENCRYPTION_KEY_PREVIOUS` 설정 시 복호 가능, 관리자 API `POST /auth/admin/tenant/rotate-keys`가 전 행 재암호화 + `key_version` 증가.
### 5.2 모델·마이그레이션 — **dev-team**
- `models.py`: `UserKisCredential`, `StockBanV2` 추가; 스코프 테이블에 `user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)`.
- alembic 0009/0010/0011 (§3.3).
### 5.3 credentials API — `backend/app/api/endpoints/kis_credentials.py` (신규, prefix `/account`) — **dev-team**
| 메서드 | 경로 | 동작 |
|---|---|---|
| GET | `/account/credentials` | 현 사용자 자격증명 마스킹 조회(`app_key_hint + "…"`, 계좌 `****` 뒤 2자리, mode, is_enabled, last_verified_at) |
| PUT | `/account/credentials` | 등록/교체 `{mode, app_key, app_secret, account_no}` → 암호화 저장, `is_enabled=False`, 레지스트리 `invalidate(user_id)` |
| DELETE | `/account/credentials` | 삭제 + invalidate |
| POST | `/account/credentials/verify` | 임시 `KoreaInvestmentAPI` 사용자 인스턴스 생성 → `get_balance()` 호출 성공(rt_cd=0) 시 `last_verified_at` 갱신·`is_enabled=True` 허용. 토큰 발급은 앱키당 1분 1회 제한(`korea_investment_api.py:334 _handle_rate_limit`)이므로 verify는 사용자당 60초 쿨다운 |
| PATCH | `/account/auto-trade` | `{enabled}` → `auto_trade_enabled` |
- 모두 `Depends(get_current_user)`; API 키 호출자는 legacy owner에 매핑. 자격증명 쓰기는 **엔드포인트에서 단일 트랜잭션 직접 커밋 허용**(sync_portfolio와 같은 성격: 읽기→쓰기 원자성 + 즉시 응답 필요; 실시간 계층 아님). CLAUDE.md §3.2 예외 목록에 추가 기재 필요.
### 5.4 `get_current_user` 의존성 — `backend/app/core/tenant_auth.py` (신규) — **dev-team**
```python
@dataclass
class CurrentUser: id: int | None; username: str; is_admin: bool; is_api_key: bool
async def get_current_user(credential: str = Depends(require_api_key), db=Depends(get_db)) -> CurrentUser
```
- 세션 토큰 → `verify_session_token` → `User` 조회(is_active 확인). API 키(`hmac.compare_digest(credential, _get_api_key())`, auth.py:159 패턴) → `get_legacy_owner(db)`(admin 최소 id; 없으면 `CurrentUser(id=None, username="api-key", is_admin=True, is_api_key=True)`).
- 라우터 등록은 그대로 `require_api_key` 유지(`api/__init__.py`), 엔드포인트 함수에 `user: CurrentUser = Depends(get_current_user)` 파라미터를 **추가하는 것은 시그니처 변경**이므로, 사용자 스코프가 필요한 엔드포인트는 `_v2` 경로(§5.6) 또는 새 라우터 파일에 작성.
### 5.5 `KISClientPool` — `backend/app/services/tenant/kis_pool.py` (신규) — **trading-agent**
- `KoreaInvestmentAPI`에 새 classmethod `for_user(user_id, app_key, app_secret, account_no, mode) -> KoreaInvestmentAPI` 추가(기존 `for_real_market_data`(:181-217) 패턴 복제, `__init__` 불변):
- `base_url = settings.KIS_REAL_URL if mode=="real" else settings.KIS_VIRTUAL_URL`
- `TOKEN_CACHE_FILE = ~/.cache/auto-stock/users/{user_id}/kis_token_{mode}.json` (chmod 600, `:305-316 _write_token_file` 재사용)
- `min_request_interval = 0.5 if mode=="virtual" else settings.KIS_REAL_MIN_REQUEST_INTERVAL` (모의 2/s, 실전 20/s)
- `_quote_delegate`는 현재 `globals()["real_market_api"]`를 보므로 사용자 인스턴스도 자동으로 공용 시세 인스턴스 사용. 단 `_real is self` 체크(:562)로 재귀 방지 유지.
- `KISClientPool.get(user_id) -> KoreaInvestmentAPI`: 복호화 후 생성·캐시(dict + asyncio.Lock). `invalidate(user_id)`는 `await inst.close()`(:278) 후 제거. legacy owner이고 `MULTI_TENANT_ENABLED=False`면 전역 `korea_investment_api` 반환.
- 실전 계좌 사용자 인스턴스는 `circuit_breaker`(:249)가 인스턴스별이므로 한 사용자의 연속 실패가 타 사용자를 차단하지 않음.
### 5.6 `UserContext` / `UserContextRegistry` — `backend/app/services/tenant/context.py`, `registry.py` (신규) — **trading-agent**(구조) + **risk-monitor-agent**(mdd/loss_tracker 팩토리)
- `UserContext`: `user_id, username, mode, kis, mdd: MDDManager, loss_tracker: ConsecutiveLossTracker, dt_state: DayTradingState, portfolio_cache: PortfolioCache, auto_trade_enabled`.
- `MDDManager`·`ConsecutiveLossTracker`·`DayTradingState`·`PortfolioCache`는 이미 클래스이므로 인스턴스만 새로 만든다(시그니처 불변). 사용자 스코프 영속화는 새 메서드로:
- `MDDManager.save_to_redis_v2(user_id)` / `load_from_redis_v2(user_id)` → 키 `mdd:state:{user_id}`.
- `ConsecutiveLossTracker.save_to_db_v2(user_id, stock_code)` → `enqueue_stock_ban_upsert_v2`; `load_from_db_v2(db, user_id)` → `StockBanV2` 필터.
- `MDDManager.execute_reduction_v2(mdd_action, kis, user_id)` — 현재 `:200`에서 전역 `korea_investment_api.get_balance()`를 쓰므로 kis 주입 버전 필요.
- `UserContextRegistry`: `get(user_id)`, `legacy()`(전역 싱글톤 래핑), `iter_active()`(`is_enabled and auto_trade_enabled` 사용자, 60초 TTL 재로드), `invalidate(user_id)`.
### 5.7 사용자 스코프 API (`_v2`) — **dev-team**
| 파일 | 추가 |
|---|---|
| `portfolio.py` | `GET /portfolio/v2`, `GET /portfolio/v2/summary`, `GET /portfolio/v2/sync` — `user: CurrentUser = Depends(get_current_user)`; Portfolio 쿼리 `.where(Portfolio.user_id == user.id)`; sync는 `ctx.kis.get_balance()` + `ctx.portfolio_cache`. 기존 `/portfolio*`는 legacy 컨텍스트로 유지 |
| `manual_buy.py` | `POST /portfolio/v2/buy` → `_buy_stock_impl_v2(request, stock_code, db, ctx)`; `enqueue_trade_history({... "user_id": ctx.user_id})`, `enqueue_portfolio_upsert({... "user_id"})` |
| `portfolio.py` `/sell`(:1285) | `POST /portfolio/v2/sell` → `safe_place_order_v2(ctx, …)` |
| `trades.py` | 기존 `GET /trades`(:51)에 user 필터 추가는 시그니처 변경 → `GET /trades/v2` |
| `swing_portfolio.py`, `day_trading.py` | 2단계 (스케줄러 사용자화와 함께) |
| `api/__init__.py` | `kis_credentials.router` 등록(`prefix="/account", dependencies=[Depends(require_api_key)]`) |
### 5.8 trade_executor 진입점 — **trading-agent**
- `safe_place_order_v2(ctx: UserContext, stock_code, order_type, quantity, price=None, is_domestic=True, exchange_code="NASD")`: 락 키 `order_lock:{ctx.user_id}:{stock_code}:{order_type}`(`_acquire_order_lock_v2`), 세마포어 키 `f"{ctx.user_id}:{stock_code}"`(`_get_order_semaphore` 재사용 — 키 문자열만 바뀜, 시그니처 불변), 주문은 `ctx.kis.place_order(...)`. `_auto_cancel_if_pending`도 `ctx.kis.cancel_order`를 쓰는 `_v2`.
- 1단계는 수동매수/매도 경로만 `_v2`로 연결. `execute_auto_trade`/`_execute_buy`/`_execute_sell`의 컨텍스트화는 2단계.
### 5.9 프론트 — **design-team**(UI) + **dev-team**(api.ts 함수)
- `frontend/src/lib/api.ts`: `getKisCredentials()`, `putKisCredentials(body)`, `deleteKisCredentials()`, `verifyKisCredentials()`, `patchAutoTrade(enabled)`.
- `frontend/src/pages/AccountSettings.tsx` (신규): 모드 라디오(모의/실전 — 실전은 확인 모달), 앱키/시크릿/계좌 입력(마스킹 표시), "연결 테스트" 버튼(60초 쿨다운 안내), 자동매매 토글, 삭제.
- `App.tsx:109-116`에 `<Route path="/account" element={<AccountSettings />} />`, `Layout.tsx:62-68` navItems에 `{ path: '/account', icon: KeyRound, label: '내 계좌' }`.
- 로그인이 API 키 방식(`localStorage.api_key`)이면 화면에 "운영자(legacy) 계좌" 배지 표시(`/auth/me`의 `auth_type`).
### 5.10 의존 순서 / 병렬 그룹
| 그룹 | 항목 | 담당 | 선행 |
|---|---|---|---|
| A (병렬) | 5.1 crypto, 5.2 모델+alembic, 5.4 get_current_user, `MULTI_TENANT_ENABLED` config | dev-team | 없음 |
| A (병렬) | `KoreaInvestmentAPI.for_user`, 5.5 KISClientPool | trading-agent | 없음 |
| A (병렬) | 5.6 mdd/loss_tracker `_v2` 메서드, `StockBanV2` 쓰기 task | risk-monitor-agent | 5.2 모델 정의만 합의 |
| A (병렬) | 5.9 AccountSettings UI 목업(api.ts 함수 시그니처 합의 후) | design-team | 없음 |
| B | 5.3 credentials API, migrate-legacy 관리자 API | dev-team | A |
| B | 5.6 UserContext/Registry 조립 | trading-agent | A |
| C | 5.7 `_v2` 엔드포인트, 5.8 `safe_place_order_v2`, db_worker user_id 필터 | dev-team + trading-agent | B |
| D | 프론트 연결·라우팅, 통합 테스트 | design-team + dev-team | C |
| E | code-verifier 검수(§8) | code-verifier-agent | D |
---
## 6. 2단계 개요
1. **스케줄러 사용자 순회**: `swing_scheduler_v2()`·`daytrading_scheduler_v2()`는 "마스터 루프"로서 `registry.iter_active()`마다 `asyncio.create_task(_run_user_swing_cycle(ctx))`를 관리(사용자별 Task 1개, 종료·예외 시 재생성). 한 사용자의 예외는 자기 Task 안에서 `try/except` → 로그 + 백오프, 타인에 영향 없음. `main.py:239-240`은 `MULTI_TENANT_ENABLED`에 따라 `_v2`를 기동.
- 사용자 사이클 내부: `check_and_execute_exit_v2(session)`(swing/exit_service.py:133) 등 기존 함수는 전역 싱글톤을 쓰므로 `ctx`를 받는 `_v3`(`check_and_execute_exit_v3(db, ctx)`, `execute_swing_entries_v2(rec, db, ctx)`, `scan_and_enter_v2(ctx)`, `check_and_exit_positions_v2(ctx)`)로 분리. 추천 데이터(`recommendations:latest`, `stock_analysis`)는 공용 — 사용자별로 재계산하지 않음.
- **레이트리밋 스케일**: 사용자 사이클에서 사용자 앱키로 나가는 호출은 `get_balance`(사이클당 1) + 주문·체결확인만. 모의 2/s면 60초 사이클에 최대 120콜 — 20종목 청산체크+진입도 여유. 시세는 공용 20/s 인스턴스로 몰리므로 **사용자 N명 × 보유 종목 수만큼 시세 콜이 증가** → 종목 단위 `quote:{stock_code}` 캐시(이미 존재, TTL 확인) 공유로 중복 제거하고, 마스터 루프에서 사용자 사이클을 `asyncio.Semaphore(MULTI_TENANT_MAX_CONCURRENT_USERS=3)`로 순차화. 토큰은 사용자 앱키별 1개(하루 1회 갱신)이므로 발급 제한 문제 없음.
2. **trade_executor 사용자 스코프**: `execute_auto_trade_v2(stock, db, ctx)` → `_execute_buy_v2/_execute_sell_v2(…, ctx)`. 내부의 `korea_investment_api`→`ctx.kis`, `mdd_manager`→`ctx.mdd`, `loss_tracker`→`ctx.loss_tracker`, `enqueue_*` payload에 `user_id`. `_calculate_kelly_position`·사이징은 `ctx.kis.get_balance()`의 총자산 기준 → §5.1 6%/20종목/레버리지 20%가 사용자 자본 기준으로 자동 적용. `correlation_filter`는 사용자 보유 목록을 인자로 받는 `_v2` 필요.
3. **db_worker**: §3.4 필터 적용 완료 + `trade_journal`·`trading_sessions` user_id.
4. **WebSocket 사용자 채널**: `websocket_manager.connect`(:26) 시 토큰에서 `username`을 뽑아 `self._client_user[client_id] = user_id` 저장(`main.py:614-621`에서 `verify_session_token` 결과 전달). `broadcast_json_to_user(user_id, data)` 신규; `trade_executor.py:916,1157`·`portfolio.py:1265`의 거래 알림은 사용자 채널로, 시세(`send_market_update`)는 기존 브로드캐스트 유지. API 키 접속(운영자)은 전체 수신.
5. **데이트레이딩 상태**: `DayTradingState`를 ctx별 인스턴스로, `restore_from_db(db)`(:256) → `restore_from_db_v2(db, user_id)`; `entry_service`의 모듈 전역 watchlist(:69-80)는 공용(모닝 배치 산출) 유지.
6. **리스크 규칙 점검 포인트**: MDD -10/-20% 트리거가 `ctx.mdd.update(사용자 총자산)`으로 호출되는지; `execute_reduction_v2`가 `ctx.kis`로 사용자 보유만 축소하는지; 3연패 ban이 `stock_bans_v2(user_id, stock_code)`로 분리되는지; 일일 -3% 손실 한도(`DAY_TRADE_DAILY_LOSS_LIMIT`)가 `ctx.dt_state.daily_pnl` 기준인지; SL은 기존 함수 그대로(`min()`) 재사용.
7. **swing_portfolio.py / day_trading.py 엔드포인트 `_v2`**, MCP `trade_monitor.py`는 API 키 = legacy owner 매핑 유지(변경 없음).
---
## 7. 리스크 & 열린 질문
| 항목 | 내용 | 대응 |
|---|---|---|
| KIS 제한 스케일링 | 시세는 공용 앱키 20/s에 집중. 사용자 10명 × 20종목 = 200종목 시세/사이클 → 10초 소요. | 종목 캐시 공유 + 사용자 사이클 동시성 3 제한 + 사이클 60s 유지. 사용자 30명 이상이면 공용 시세 앱키 2개 라운드로빈(2단계 후속) |
| 공용 시세 앱키 부재 | 운영자가 `KIS_REAL_APP_KEY` 미설정이면 `real_market_api=None` → 각 사용자 모의 앱키 2/s로 시세까지 부담 | 멀티테넌트 활성 조건에 `real_market_api is not None` 권고(경고 로그) |
| 실전 계좌 오류 격리 | 한 사용자의 잘못된 자격증명이 스케줄러 전체를 막는 것 | 사용자별 Task + 인스턴스별 circuit_breaker; verify 실패 시 `is_enabled=False` 자동 전환(3회 연속 401/403) |
| 토큰 캐시 충돌 | 동일 앱키를 두 사용자가 등록하면 KIS가 토큰을 재발급해 서로 무효화 | `user_kis_credentials`에 `app_key_hash(sha256)` 컬럼 + unique → 중복 등록 409 |
| 기존 데이터 백필 | 관리자 사용자가 없는 상태에서 0011 실행 | 스킵 후 migrate-legacy API로 지연 백필; user_id NULL 행은 legacy 컨텍스트에서 계속 조회 가능(0012 전) |
| 자격증명 유출 | DB 덤프 유출 시 SECRET_KEY만 있으면 복호 가능 | `CREDENTIALS_ENCRYPTION_KEY` 별도 설정 권고, 응답에 평문 절대 미포함, 로그 마스킹(`app_key[:6]`) |
| PortfolioCache/`/portfolio/sync` | 사용자별 캐시·락 → 메모리 증가는 미미하나 `sync_lock` 30초 타임아웃(portfolio.py:463)이 사용자별로 병행 | ctx별 PortfolioCache 인스턴스 |
| `korea_investment_api_adapter.py`/`app/trading/*` | 별도 클라이언트 계층(`kis_client`)이 존재하나 `app/services`에서는 미사용(`strategy/scanner.py`만 참조) | 본 설계 범위 외. 멀티테넌트에서는 `KoreaInvestmentAPI.for_user`만 사용 |
| 열린 질문 | (1) 사용자당 모의+실전 2세트 동시 허용 여부 (1단계: 1세트) (2) 관리자가 타 사용자 포트폴리오 열람 가능해야 하는가(`?user_id=` 관리자 전용 파라미터) (3) 사용자별 전략 파라미터 오버라이드(`strategy_configs`는 공용) 필요 여부 | PM 결정 필요 |
**테스트 전략**: `backend/tests/test_api_endpoints.py`는 SQLite(:90)라 JSONB/FK CASCADE 검증 불가 → 컨테이너 Postgres(`docker compose run --rm backend pytest -m integration`)용 `tests/integration/test_multi_tenant.py` 신설: (a) 두 사용자 자격증명 등록·마스킹 조회, (b) `upsert_portfolio` user_id 필터로 동일 종목 2행 공존, (c) `stock_bans_v2` 사용자 분리, (d) `safe_place_order_v2` 락 키 분리(fakeredis), (e) `MULTI_TENANT_ENABLED=False`에서 기존 `/portfolio` 응답 불변 스냅샷.
---
## 8. 검증 계획
**code-verifier 체크리스트**
1. 기존 함수/클래스 시그니처 diff 0건 (`korea_investment_api.__init__`, `safe_place_order`, `enqueue_*`, `MDDManager`/`ConsecutiveLossTracker` 기존 메서드, 스케줄러 함수).
2. 자격증명 API 외 어떤 경로에서도 `AsyncSessionLocal()`/`commit()`이 실시간 계층(콜백·웹소켓·스케줄러 사이클 내부)에 추가되지 않았는지; 추가된 DB 쓰기가 db_worker 경유인지.
3. 응답·로그에 app_secret/app_key 평문 없음(grep `app_secret` in logger/return).
4. Redis 락 키·mdd 키에 user_id 포함, legacy 경로는 기존 키 문자열 그대로.
5. `_v2` 진입 경로의 리스크 게이트: `ctx.mdd.can_open_new_position()`, `ctx.loss_tracker.is_banned_safe()`, `MAX_HOLDINGS_COUNT`가 사용자 보유 수 기준, `record_trade_result_safe()` 뒤 `save_to_db_v2()` 호출.
6. SL 계산 함수 재사용 확인(`min()`), `max()` 신규 도입 없음.
7. 새 쓰기 엔드포인트 전부 `require_api_key` 라우터 하위 + `get_current_user`; 관리자 API는 `require_admin`.
8. `_dumps()` 사용, `json.dumps` 신규 0건.
9. alembic 0009~0011 idempotent(재실행 안전), downgrade 작성.
10. `MULTI_TENANT_ENABLED=False` 스냅샷 테스트 통과.
**백테스트 영향**: 없음. 전략 시그널·SL/TP·사이징 공식은 변경하지 않고 실행 컨텍스트(계좌·상태 저장소)만 사용자 단위로 분리하므로 Walk-forward 재검증 불필요. 단 2단계에서 `correlation_filter_v2`가 사용자 보유 기준으로 바뀌는 것은 로직 변경이 아니라 입력 스코프 변경이므로 동일하게 면제. 사이징 기준 자본이 "계좌 총자산"인 점은 기존과 동일.

View File

@@ -0,0 +1,148 @@
# 멀티테넌트 1단계 검증 보고서 (2026-09-21, 브랜치 `dev` 미커밋 변경분)
검수 범위: `git status` 의 백엔드/프론트 변경 23파일 + 신규 17파일, `docs/multi_tenant_design.md`, CLAUDE.md. 읽기 전용(코드 수정·컨테이너 조작 없음). `ast.parse` 29파일 전부 통과, `npx tsc --noEmit` exit 0. 로컬에 fastapi 미설치로 import 실행 검사 불가 → 정적 순환 임포트 분석으로 대체(순환 없음: `auth.py↔bootstrap.py` 는 양쪽 모두 함수 내 지연 import).
---
## High (배포 차단)
### H1. 배포 순서 결함 — `alembic upgrade head` 없이 새 이미지를 올리면 레거시 경로 전체 500
- `backend/app/models/models.py:47,130,165,213,228,278,332` 에 `user_id` 컬럼 추가. `main.py:159` 주석대로 스키마는 `create_all` 만 실행되고 alembic 은 CLI 전용인데, `create_all` 은 기존 테이블에 컬럼을 추가하지 않음. Dockerfile CMD 는 `uvicorn` 직행, compose 에도 alembic 단계 없음.
- 영향: SQLAlchemy 는 매핑된 모든 컬럼을 명시 SELECT/INSERT 하므로 `portfolio/trade_history/active_trades/trade_journals/trading_sessions` 관련 모든 기존 엔드포인트와 db_worker 태스크(insert_trade_history, upsert_portfolio 등)가 `UndefinedColumnError` 로 실패. MT 기능과 무관하게 자동매매 기록이 전부 유실됨.
- 권장: 배포 절차에 `alembic current` 확인 → `alembic upgrade head` → `docker compose up -d --build` 명문화. `main.py` lifespan 에 `information_schema` 로 `portfolio.user_id` 존재 확인 후 없으면 명확한 에러 로그+기동 중단(또는 경고). 담당: dev(mt-a-dev) + 배포 문서.
### H2. `get_current_user` 가 비활성/삭제 사용자를 legacy owner 로 폴백 → 회수된 계정이 owner 계좌로 매매 가능
- `backend/app/core/tenant_auth.py:87-99`: `_resolve_session_user` 결과가 `None` 또는 `is_active=False` 면 예외 대신 `CurrentUser(id=legacy_owner_id, ...)` 반환. `security.require_api_key`(`security.py:72-91`)는 JWT 서명/exp/aud 만 검증하고 `is_active` 를 보지 않으므로 이 경로는 "비정상 경합"이 아니라 revoke 직후 토큰 만료(24h)까지 항상 도달함.
- 영향: MT=True 에서 `registry.resolve()`(`registry.py:160-166`)가 `current_user.id == legacy_ctx.user_id` 로 legacy 컨텍스트를 반환 → 비활성 사용자가 `/portfolio/v2/buy|sell` 로 legacy owner 의 KIS 계좌에 주문, `/portfolio/v2`·`/trades/v2` 로 owner 데이터 열람. MT=False 에서도 owner 데이터 열람.
- 권장: 두 경우 모두 `HTTPException(401)` 로 거부. 담당: dev(mt-b-dev).
### H3. 자격증명 변경/삭제/자동매매 OFF 가 캐시된 `UserContext` 에 반영되지 않음
- `backend/app/api/endpoints/kis_credentials.py:183,200` 은 `kis_client_pool.invalidate()` 만 호출. `user_context_registry.invalidate()` 호출처는 코드베이스에 0건(grep 확인). `registry.get()`(`registry.py:59-61`)은 `_contexts` 캐시 히트 시 DB 를 보지 않음.
- 영향: PUT(키 교체, `is_enabled=False` 로 리셋)·DELETE 후에도 캐시된 ctx 가 구 app_key 로 만든 KIS 인스턴스를 계속 보유(httpx 는 `is_closed` 체크로 자동 재생성) → 삭제·교체된 자격증명으로 재시작 전까지 주문 계속 가능. `PATCH /auto-trade enabled=false` 도 `ctx.auto_trade_enabled` 에 반영 안 됨 → `buy_stock_v2:270` 가드 무력화.
- 권장: 세 엔드포인트에서 `await user_context_registry.invalidate(user_id)` 호출(내부에서 kis_pool 도 정리, 순환 임포트 없음). 담당: dev(mt-b-dev).
### H4. `tests/integration/test_kis_credentials.py` 가 운영 DB 를 직접 대상으로 함
- 파일 docstring(`:4-13`) 자체가 "실제 `AsyncSessionLocal`(운영 DB)에 대해 검증", 실행 예시 `docker exec autostock-backend python -m pytest ...`. 라이브 DB 가드 없음, 테스트 사용자 생성/삭제를 운영 DB 에서 수행. 중간 실패 시 잔여 행.
- `test_multi_tenant.py:38-43` 가드는 `os.environ["DATABASE_URL"]` 만 검사 → env 미설정 시 `settings.DATABASE_URL` 기본값(`...:5432/autostock_db`, 컨테이너 내부면 라이브 postgres)으로 떨어지는데 가드를 통과함. `test_h_bootstrap_admin_seed_and_idempotent` 는 `_backfill_legacy_owner_ids` 로 실제 UPDATE 를 수행.
- 권장: 두 파일 모두 `settings.DATABASE_URL` 로 가드하고, 임시 DB 마커(예: DB 명 `_test` 접미사) 필수화. 담당: mt-c-dev(테스트).
---
## Medium
### M1. `CurrentUser.effective_user_id`(None) vs `UserContextRegistry.legacy().user_id`(0) 불일치
- 위치: `tenant_auth.py:47,85`(None) vs `registry.py:128`(`owner_id = 0`).
- 통일 방향: `None`. `users.id=0` 은 존재하지 않으므로 0 은 FK 위반 값이고, `Optional[int]` 타입(`context.py:36`)과도 맞음.
- 실제 오동작 경로(관리자 미존재 + API 키 호출, 즉 초기 배포/MCP 서버 호출):
- `portfolio_v2.py:268,406` `ctx.user_id is None` 가드를 `0` 이 통과 → 주문 실행 후 `enqueue_trade_history({"user_id": 0})`(`:355,443`) → `users` FK 위반 → db_worker 3회 재시도 후 폐기 → 주문은 나갔는데 거래 기록 유실. `enqueue_portfolio_upsert:373`, `enqueue_close_active_trade_v2:465` 동일.
- MT=True 시 `_user_scope_filter(Portfolio.user_id, 0)` → 빈 결과 → 매도 항상 404(fail-closed), 매수는 보유수 0 으로 계산돼 `MAX_HOLDINGS_COUNT` 가드 무의미.
- `_resolve_target_user_id` 는 None 이면 400 → GET 계열은 fail-closed(정상).
- 권장: `registry.legacy()` 에서 `owner_id = owner.id if owner else None`, owner 미확정 상태면 캐시하지 않기(현재 60초 TTL 동안 0 고정). 담당: mt-a-trading.
### M2. legacy 컨텍스트가 `safe_place_order_v2` 를 타면 락 키가 기존 경로와 달라 이중 매도 방지가 깨짐
- `trade_executor.py` `safe_place_order_v2` 는 항상 `order_lock:{user_id}:{code}:{type}` + 세마포어 키 `{user_id}:{code}` 사용. 자체 docstring 과 설계문서 §2-4/§10-4("legacy 경로는 기존 키 그대로") 위반. MT=False(기본)에서 세션 사용자가 v2 매도 → `order_lock:{owner}:005930:SELL`, 동시에 swing/daytrading exit_service 는 `order_lock:005930:SELL` → 같은 계좌 같은 종목 동시 매도 가능.
- 기존 `/portfolio/sell` 도 락이 없었으므로 회귀는 아니지만 설계 의도 미달. 권장: `ctx.is_legacy` 면 `_acquire_order_lock`/`_get_order_semaphore(stock_code)` 로 분기. 담당: mt-a-trading.
### M3. MT=True 에서 legacy 스케줄러가 쓰는 행(`user_id=NULL`)이 owner 의 v2 화면에서 사라지고 ActiveTrade 종료가 누락
- swing/daytrading/trade_executor/portfolio.py/manual_buy.py 어느 곳도 payload 에 `user_id` 를 넣지 않음(grep 0건; 설계 §4 "DB 쓰기 payload에 user_id를 넣되" 미구현). 부트스트랩 백필은 기동 시 1회.
- 영향: MT=True 시 `_user_scope_filter` 가 `== owner` 로 엄격해져 기동 이후 자동매매로 생긴 보유/이력이 `/portfolio/v2`, `/trades/v2` 에서 비표시, `sell_stock_v2` 404. MT=False 에서도 `sell_stock_v2:465` 의 `enqueue_close_active_trade_v2(owner, ...)` 가 `ActiveTrade.user_id == owner` 필터(`db_worker.py:587`)로 NULL 행을 못 찾아 전량 수동매도 후 ActiveTrade 가 active 로 잔존 → exit_service 가 유령 포지션 추적.
- 권장: 1단계에서는 `enqueue_close_active_trade_v2` 를 legacy ctx 에서 `user_id=None` 으로 호출(기존 동작), 또는 `_user_scope_filter` 를 close 에도 적용. MT=True 전환 전제조건으로 legacy 쓰기 경로 user_id 주입 필요. 담당: mt-a-trading.
### M4. `registry.get()` 이 `asyncio.Lock` 안에서 DB I/O + Redis + PBKDF2 CPU 작업 수행
- `registry.py:63-111`: 락 내부에서 `db.execute`, `decrypt`×3(PBKDF2 200k 회 × 3 ≈ 0.3~0.6초 동기 CPU, `crypto.py:28-32` 파생키 캐시 없음), `load_user_risk_state`(Redis+DB). CLAUDE.md §3-4 위반이며 모든 사용자 컨텍스트 조립을 직렬화 + 이벤트 루프 정지(실시간 스케줄러 지연).
- `kis_credentials._to_out:103` 도 GET 마다 `decrypt` 실행 → 요청당 ~0.2초 루프 블로킹.
- 권장: `_derive_key` 에 `functools.lru_cache`, 복호화는 `asyncio.to_thread`, 락은 dict 갱신 구간만. 담당: mt-b-dev / mt-a-trading.
### M5. 프론트 `useTenantMode` 로딩 중 `legacy` 반환 → 세션 사용자에게 owner 데이터 노출/오주문 창
- `frontend/src/lib/tenant.ts:37`. `usePortfolioData` 는 `['portfolio-combined','legacy']` 키로 `/portfolio/combined` 를 즉시 호출해 결과를 캐시. MT=True 에서 이는 타 사용자(owner) 보유 정보 노출이며, 그 창에 매수/매도 클릭 시 `useTradeActions` 가 legacy `/portfolio/sell` 로 owner 계좌에 주문.
- 근본 원인은 백엔드: legacy `/portfolio*`, `/trades*` 가 MT=True 에서도 모든 세션 사용자에게 열려 있음(설계 §5.7 "기존 /portfolio* 는 legacy 컨텍스트로 유지"). 프론트 가드는 완화책일 뿐.
- 권장: (프론트) `mode: 'v2' | 'legacy' | undefined`, 로딩 중 쿼리 `enabled: false`. (백엔드, MT=True 전환 전제) legacy 라우터에 "MT=True 이고 세션 사용자가 owner 아니면 403" 의존성. 담당: mt-d-dev / mt-b-dev.
### M6. `resetPendingTrades` → `DELETE /trades/pending` 가 사용자 무관 전체 삭제
- `useTradeActions.ts:97-99` v2 전환 없이 legacy 유지, `trades.py:192-209` 는 `user_id` 조건 없음 → MT=True 에서 일반 사용자가 타 사용자의 PENDING/FAILED 이력 삭제 가능. 권장: `/trades/v2/pending` 추가 또는 v2 모드에서 버튼 숨김. 담당: mt-d-dev.
### M7. db_worker `scalar_one_or_none()` 이 사용자 다중화로 `MultipleResultsFound` 가능
- `db_worker.py:543,589`(`upsert_active_trade`/`close_active_trade`) legacy 호출(`user_id=None`) 시 필터 미적용인데 MT 로 두 사용자가 같은 종목 active 면 예외 → 태스크 3회 실패 후 폐기. `user_id=None` 경로 자체는 기존과 동일(`is not None` 체크 정확 — `== None` 실수 없음). 권장: `.limit(1)` 또는 `scalars().first()`. 담당: mt-e-risk-fix.
### M8. 부트스트랩/이관 API 의 백필이 첫 실패 후 조용히 전부 무효화
- `bootstrap.py:122-146`, `auth.py:337-349`: PostgreSQL 은 트랜잭션 내 한 문장 실패 시 이후 문장이 모두 `InFailedSqlTransaction` → 개별 try/except 가 삼켜 `backfilled` 카운트가 0 으로 오보되고 실제 UPDATE 미실행. 권장: 문장별 `SAVEPOINT`(`session.begin_nested()`) 또는 사전 `information_schema` 체크. 담당: mt-b-dev.
---
## Low
- L1. `mdd_manager.py:159` `json.dumps` 신규 사용(§4 `_dumps()` 규칙). 기존 `:44` 도 동일 패턴이라 일관성 차원.
- L2. `crypto.decrypt` `ValueError` → `registry.get:80` 은 "계좌 미등록" 400 으로 안내(원인 은폐), `kis_credentials._to_out:103` 은 미처리 → 500. 키 로테이션 실패 시 진단 어려움.
- L3. Alembic `0009` downgrade `drop_index("ix_user_kis_credentials_app_key_hash")` — 테이블이 `create_all` 로 생성된 경우 unique 는 constraint(`..._app_key_hash_key`)라 인덱스가 없어 downgrade 실패. `0010` idempotent OK, FK `ondelete` 모델과 일치 OK(SET NULL / CASCADE). 사용자 삭제 엔드포인트가 없어 CASCADE↔SET NULL 정책 충돌은 현재 비발생.
- L4. `registry.legacy()` 의 `auto_trade_enabled=settings.AUTO_TRADE_ENABLED` 스냅샷은 60초 TTL 동안 고정 — 런타임 토글 지연.
- L5. `kis_credentials.verify_credentials:235` `detail=f"...{e}"` 로 KIS 예외 원문 노출 — 현재 KIS 오류 본문에 키는 없으나 방어적으로 예외 타입만 노출 권장. 평문 키/시크릿/비밀번호는 응답·로그 어디에도 없음 확인(hint 6자만, `BOOTSTRAP_ADMIN_PASSWORD` 미로깅).
---
## 확인 통과 항목
- 시그니처 변경: `git diff` `-` 라인에 def/class 변경 0건. 신규는 전부 `_v2`/`for_user`/신규 파일.
- 실시간 계층 `AsyncSessionLocal()` 신규 호출 없음(bootstrap 은 lifespan 1회, 허용).
- 직접 commit 신규 경로: `kis_credentials`(설계 §5.3 허용), `sync_portfolio_v2`(sync 예외), `sell_stock_v2:469`(기존 `/portfolio/sell:1365` 와 동일 패턴). 정책 내.
- `require_api_key`: `portfolio_v2`/`trades_v2`/`kis_credentials` 라우터 등록 시 의존성 부여(`api/__init__.py:30-52`). `/auth/admin/tenant/*` 는 `require_admin`(내부적으로 `require_api_key` 의존) 강제.
- 관리자 `?user_id=` 오버라이드: `_resolve_target_user_id:69`/`_resolve_ctx:80` 비관리자 403, GET 2개 + trades_v2 모두 경유.
- `app_key_hash` 중복 체크 `user_id != user_id` 로 자기 갱신 오탐 없음(`:151`).
- JWT: `aud`/`exp` 검증(`security.py:58`).
- `.env` 미추적(`.env.example` 만), `git diff --cached` 비어 있음.
- `sync_portfolio_v2` SELECT/INSERT/DELETE 모두 `Portfolio.user_id == ctx.user_id`(`:193,219,230-232`).
- `execute_reduction_v2` 큐 payload 3곳 모두 `user_id` 포함.
- `registry.get()` `is_enabled=False` 거부(`:72`), `invalidate` 시 `_active_cache=None`(`:214`).
- `for_user` 속성 집합 = `for_real_market_data` ⊇ + `mode`,`user_id`; `_last_token_request_time` 은 `hasattr` 가드.
- SL 관련 `max()` 신규 도입 없음.
## CLAUDE.md §8.1 체크리스트
1. DB 쓰기 db_worker 경유 — ✅ (허용 예외만 직접 commit)
2. §5 리스크 규칙 — ✅ (buy_v2 에 가격필터/ban/MDD/최대보유 적용) — 단 M1 로 legacy·무관리자 시 보유수 가드 무의미
3. SIDEWAYS 진입 제안 금지 — 해당 없음 ✅
4. 백테스트 없이 실전 투입 금지 — 전략 변경 없음 ✅
5. SL `min()` 만 — ✅
6. `record_trade_result` 후 `save_to_db` — v2 경로에 record 호출 없음(해당 없음) ✅
7. 쓰기 API `require_api_key` — ✅
8. `_dumps()`/새 파일/시그니처 불변 — ❌ (L1 `json.dumps` 1건; 나머지 ✅)
## 배포 가능 여부
**불가.** High 4건(H1 배포 순서, H2 비활성 사용자 폴백, H3 컨텍스트 캐시 미무효화, H4 운영 DB 테스트). H1~H3 는 각 1~5줄 수정으로 해소 가능. MULTI_TENANT_ENABLED=True 전환은 추가로 M1·M3·M5(백엔드 legacy 라우터 격리) 해소가 전제.
---
# 재검증 (2026-09-21, 4팀 수정 반영 후 `dev` 워킹트리)
읽기 전용 재검수. `ast.parse` 30파일 OK, `npx tsc --noEmit` exit 0. `git diff` `-` 라인 재점검: 함수/클래스 시그니처 변경 없음(`portfolio.py` `PortfolioResponse` 필드 `Optional[...]` 화는 기존 알려진 pydantic v2 버그 수정, 별도 항목). 순환 임포트 신규 없음(`api/__init__.py → core/tenant_auth`, `kis_credentials → services/tenant/registry` 모두 endpoints 로 되돌아오지 않음).
## 항목별 판정
| 항목 | 판정 | 근거 |
|---|---|---|
| H1 alembic 배포 순서 | ✅ 해소 | `main.py:90-109` `information_schema` 검사 — `create_all`(`:87`) 직후, 부트스트랩(`:184`) 이전, `try` 블록 내 `raise RuntimeError` → 기동 중단. `Jenkinsfile:81-88` DB Migration 스테이지가 Deploy(`:90`) 앞으로 이동, `\|\| echo WARNING` 제거로 실패 시 파이프라인 중단. `docker-compose run --rm --no-deps backend alembic upgrade head`: compose `backend` 서비스 `env_file: .env`(DEPLOY_DIR 기준) + `autostock-network` 부착 + 컨테이너 내 `/.dockerenv` 존재로 `config._apply_docker_db_override` 가 `localhost→postgres` 치환(`config.py:369-372`), `WORKDIR /app` 에 `alembic.ini` 존재 → NAS 환경 동작 가능. 단 전제 2건은 아래 N1 참조 |
| H2 비활성 사용자 폴백 | ✅ 해소 | `tenant_auth.py:97-105` `user is None or not is_active` → 401. API 키 경로(`:76-85`) 변경 없음 — legacy owner 매핑 유지, owner 미존재 시 `id=None, is_api_key=True` |
| H3 컨텍스트 캐시 미무효화 | ✅ 해소 | `kis_credentials.py:201,218,312` PUT/DELETE/PATCH 모두 `user_context_registry.invalidate(user_id)` (내부에서 `kis_client_pool.invalidate` 호출, `registry.py:226-231`) |
| H4 운영 DB 테스트 | ✅ 해소 | `tests/integration/conftest.py:44-52` `settings.DATABASE_URL` DB명 `_test` 접미사 강제, 미충족 시 `pytest.exit`. 두 테스트 파일이 공용 conftest 경유. 부작용 N3 참조 |
| M1 effective_user_id vs legacy 0 | ✅ 해소 | `registry.py:147-172` `owner_id: int \| None = None`, `_legacy_owner_confirmed` 로 owner 미확정 시 TTL 캐시 미적용. `portfolio_v2.py:59-73` `_user_scope_filter` MT=True & None → `sql_false()`(fail-closed), `column == None` 의 `IS NULL` 컴파일 함정 회피. buy/sell 가드 `ctx.user_id is None and not ctx.is_legacy`(`:282`) |
| M2 legacy 락 키 | ✅ 해소 | `trade_executor.py` `safe_place_order_v2` `ctx.is_legacy` 분기 → `_acquire_order_lock`/`_release_order_lock`/`_get_order_semaphore(stock_code)` (v1 과 동일 키) |
| M3 legacy 행 user_id 정합 | 🟡 부분 | (1단계) `portfolio_v2.py:386-407,485-496` legacy ctx 는 trade_history/portfolio payload 에 `user_id` 미포함 + `enqueue_close_active_trade`(v1) 사용 → 기존 v1 과 동일 ✅. (MT=True 전제) 스윙/데이트레이딩 스케줄러 쓰기 경로에 `user_id` 주입은 여전히 없음 → MT=True 에서 `_user_scope_filter == owner` 가 기동 이후 자동매매 행(NULL)을 owner 의 v2 화면에서 제외. 설계 §4 미구현 그대로 — MT=True 전환 전 필수 |
| M4 락 내 I/O·PBKDF2 | ✅ 해소 | `registry.py:57-116` 락은 dict 갱신(더블체크)만, `decrypt` 는 `asyncio.to_thread`, `crypto.py:36` `_derive_key` `lru_cache`. 동일 user_id 동시 요청: `kis_client_pool.get_or_create` 가 자체 락+캐시로 KIS 인스턴스 단일 보장 → 이중 생성/누수 없음(중복 조립되는 MDD/loss_tracker 객체는 패자 측이 버려짐, 외부 자원 없음). `kis_credentials.py:100-119` `_to_out` 도 to_thread |
| M5 프론트 legacy 플래시 / 백엔드 legacy 라우터 격리 | 🟡 부분 | 프론트: `tenant.ts:30-55` `mode: 'v2'\|'legacy'\|undefined`, `usePortfolioData.ts:100` `enabled: mode !== undefined`, `useTradeActions.ts:48` `isReady`, `Dashboard.tsx:797,838,844` 버튼 disabled, `TradeHistory.tsx:352` 동일 ✅. `/auth/me`(`auth.py:250-258`) API 키 → `auth_type: "api_key"` → legacy 정상 ✅. 백엔드: `api/__init__.py:44,48,100` `portfolio`/`manual_buy`/`trades` 라우터에 `deny_non_owner_session_when_mt`(`tenant_auth.py:108-128`: MT=False 통과, `is_api_key` 통과 → MCP 서버 영향 없음, 스케줄러는 HTTP 를 타지 않아 무관) ✅. 잔여: `swing_portfolio`/`day_trading`/`daytrading_v2`/`us_trading`/`journals`/`risk_management`/`recommendations` 등 나머지 legacy 라우터는 MT=True 에서 여전히 모든 세션에 노출(설계 §7-3 2단계 이관 대상) — MT=True 전환 전제 |
| M6 DELETE /trades/pending 전체 삭제 | ✅ 해소 | `trades_v2.py:96-128` `/trades/v2/pending` `_user_scope_filter` 적용, `useTradeActions.ts:114-119` v2 분기 |
| M7 scalar_one_or_none MultipleResultsFound | ✅ 해소 | `db_worker.py:540-550,592-602` legacy(user_id None) 경로 `order_by(id.desc()).limit(1)` + `scalars().first()` |
| M8 백필 InFailedSqlTransaction | ✅ 해소 | `bootstrap.py:128-146` 문장별 `begin_nested()`, `auth.py:336-345` 동일 |
| L1 json.dumps | ✅ 해소 | `mdd_manager.py:13,46,161` `_dumps` |
| L2 decrypt 오류 진단 | ✅ 해소 | `crypto.py:29` `CredentialDecryptError`, `registry.py:83-87` 400 "복호화 실패" 명시, `_to_out` `decrypt_error` 플래그 |
| L3 0009 downgrade | ✅ 해소 | `0009:50-66` `pg_indexes` 존재 확인 후 drop |
| L4 AUTO_TRADE_ENABLED 스냅샷 | ✅ 해소 | `context.py:48-` `effective_auto_trade_enabled` 프로퍼티, `portfolio_v2.py:286` 사용 |
| L5 KIS 예외 원문 노출 | ✅ 해소 | `kis_credentials.py:260` `type(e).__name__` 만 노출 |
## 신규 발견 (수정 과정 부작용 / 전제조건)
- **N1 (Medium, 배포 전제조건)** `alembic upgrade head` 의 `alembic_version` 선행 상태. 이전 파이프라인의 `docker-compose exec ... \|\| echo WARNING` 이 실제 성공했는지 불명. 운영 DB 가 `create_all` 로만 만들어져 `alembic_version` 이 비어 있으면 `upgrade head` 가 0001 부터 실행되고, `0003_add_strategy_type.py:23-53` `add_column`/`create_index` 는 존재 가드가 없어 `DuplicateColumn` 으로 실패 → 이제는 파이프라인이 중단(안전하지만 배포 불가). 첫 배포 전 `alembic current` 확인 후 비어 있으면 `alembic stamp 0008` 필요 — Jenkinsfile 또는 배포 문서에 명시 권장. 또한 `--no-deps` 는 postgres 컨테이너가 이미 기동 중임을 전제(최초 배포 환경에선 Deploy 이전이라 실패).
- **N2 (Low)** `get_current_user` 가 `deny_non_owner_session_when_mt` 경유로 모든 `/portfolio*`·`/trades*` 요청에 붙어 요청당 DB 쿼리 2회(`get_legacy_owner`, 사용자 조회) 추가 — MT=False 에서도 실행됨(플래그 검사가 의존성 해석 뒤). 30초 폴링 수준에선 무시 가능하나 `MULTI_TENANT_ENABLED=False` 면 `get_current_user` 를 건너뛰는 조기 반환으로 줄일 수 있음.
- **N3 (Low)** `tests/integration/conftest.py:47` `pytest.exit` 는 `backend/` 에서 인자 없이 `pytest` 실행(단위 테스트 전체 수집) 시에도 발동 → 라이브 DB 를 안 건드리는 기존 단위 테스트 실행까지 중단. `pytest.ini` 에 `testpaths = tests` + `norecursedirs = tests/integration`(또는 `addopts = --ignore=tests/integration`) 로 기본 수집에서 제외하고 통합 테스트는 명시 경로로만 실행하도록 권장. Jenkins 는 pytest 를 실행하지 않아 CI 영향 없음.
- **N4 (Low, UX)** `useTenantMode` 가 `/auth/me` 실패(네트워크 오류 등) 시 `undefined` 를 유지하고 `staleTime` 5분·`refetchOnWindowFocus:false` 라 재시도 소진 후 대시보드가 빈 상태·버튼 비활성으로 고정될 수 있음. `retry`/수동 재시도 버튼 고려.
## 최종 판정
**배포 가능(MULTI_TENANT_ENABLED=False 기본값 기준).** High 4건 모두 해소, Medium 8건 중 6건 해소·2건(M3, M5) 은 1단계 범위 내 부분 해소이며 잔여분은 MT=True 전환 전제조건. 배포 시 N1(`alembic current` 확인/필요 시 `stamp 0008`) 을 먼저 수행할 것.
**MULTI_TENANT_ENABLED=True 전환은 아직 불가** — M3 잔여(legacy 스케줄러 쓰기 경로 `user_id` 주입 또는 owner 에 대해 NULL 행 포함 필터), M5 잔여(나머지 legacy 라우터 격리) 해소 후.

View File

@@ -27,6 +27,7 @@ const MCPStatus = React.lazy(() => import('./pages/MCPStatus'))
const StrategySettings = React.lazy(() => import('./pages/StrategySettings'))
const Backtest = React.lazy(() => import('./pages/Backtest'))
const Analysis = React.lazy(() => import('./pages/Analysis'))
const AccountSettings = React.lazy(() => import('./pages/AccountSettings'))
/** ErrorBoundary 상태 인터페이스 */
interface ErrorBoundaryState {
@@ -113,6 +114,7 @@ function App() {
<Route path="/strategy" element={<StrategySettings />} />
<Route path="/backtest" element={<Backtest />} />
<Route path="/analysis" element={<Analysis />} />
<Route path="/account" element={<AccountSettings />} />
</Routes>
</Suspense>
</ErrorBoundary>

View File

@@ -0,0 +1,59 @@
/**
* AccountSetupNotice.tsx — 계좌 미등록/미검증 빈 상태 안내 컴포넌트
*
* 사용처:
* - PortfolioSummary / PortfolioPanel: v2 API가 400(계좌 미등록·미검증)을 반환할 때
* - Dashboard: 매수/매도 요청이 400/423(자동매매 비활성)로 거부될 때
*
* 정책: 에러 토스트 대신 인라인 안내 + `/account` 이동 버튼 (일회성 alert 금지).
*/
import { Link } from 'react-router-dom'
import { ShieldAlert } from 'lucide-react'
interface AccountSetupNoticeProps {
/** 안내 문구 — 백엔드 detail 메시지를 그대로 넘기거나 기본 문구 사용 */
message?: string
/** 컴팩트 배너(한 줄) 모드 — 목록/버튼 인근처럼 좁은 영역용 */
compact?: boolean
className?: string
}
const DEFAULT_MESSAGE = 'KIS 계좌가 등록·검증되지 않았습니다. 내 계좌 설정에서 계좌를 등록·검증하세요.'
const AccountSetupNotice = ({ message, compact = false, className = '' }: AccountSetupNoticeProps) => {
if (compact) {
return (
<div
className={`flex items-center gap-2 px-3 py-2 rounded-sm border border-yellow-500/30 bg-yellow-500/10 text-yellow-400 text-[10px] ${className}`}
role="alert"
>
<ShieldAlert size={12} className="flex-shrink-0" />
<span className="flex-1 leading-tight">{message || DEFAULT_MESSAGE}</span>
<Link
to="/account"
className="flex-shrink-0 px-2 py-0.5 rounded-sm border border-yellow-500/40 hover:bg-yellow-500/20 transition-colors whitespace-nowrap"
>
계좌 설정
</Link>
</div>
)
}
return (
<div
className={`flex items-center gap-3 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/10 text-yellow-400 text-xs mb-3 ${className}`}
role="alert"
>
<ShieldAlert size={16} className="flex-shrink-0" />
<span className="flex-1">{message || DEFAULT_MESSAGE}</span>
<Link
to="/account"
className="flex-shrink-0 px-3 py-1.5 rounded-md border border-yellow-500/40 hover:bg-yellow-500/20 transition-colors font-medium whitespace-nowrap"
>
내 계좌 설정으로 이동
</Link>
</div>
)
}
export default AccountSetupNotice

View File

@@ -25,6 +25,8 @@ interface JournalPanelProps {
tradeHistory?: TradeHistory[] | undefined
/** 미체결 주문 정리 콜백 */
onResetPending: () => void
/** true면 미체결 정리 버튼 비활성화 (예: 사용자 모드 확정 전) */
resetDisabled?: boolean
}
/**
@@ -33,7 +35,7 @@ interface JournalPanelProps {
* 상태(State):
* - tab: 현재 활성 탭 ('trades' 또는 'journal')
*/
const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending }: JournalPanelProps) => {
const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, resetDisabled }: JournalPanelProps) => {
const [tab, setTab] = useState<'trades' | 'journal'>('trades')
return (
@@ -69,7 +71,8 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending }: Jou
</div>
<button
onClick={onResetPending}
className="px-2 py-0.5 text-[9px] font-mono text-[#3a5570] hover:text-[#e84444] border border-[#152035] hover:border-[#e84444]/30 rounded-sm transition-colors"
disabled={resetDisabled}
className="px-2 py-0.5 text-[9px] font-mono text-[#3a5570] hover:text-[#e84444] border border-[#152035] hover:border-[#e84444]/30 rounded-sm transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-[#3a5570] disabled:hover:border-[#152035]"
title="미체결 주문 정리"
>
미체결 정리

View File

@@ -14,7 +14,7 @@
* [Main] — 페이지 컨텐츠 영역
*/
import { Link, useLocation } from 'react-router-dom'
import { LayoutDashboard, History, Cpu, Activity, SlidersHorizontal, BarChart3, TrendingUp } from 'lucide-react'
import { LayoutDashboard, History, Cpu, Activity, SlidersHorizontal, BarChart3, TrendingUp, KeyRound } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import api from '../lib/api'
@@ -66,6 +66,7 @@ const Layout = ({ children }: LayoutProps) => {
{ path: '/backtest', icon: BarChart3, label: '백테스트' },
{ path: '/trades', icon: History, label: '매매 로그' },
{ path: '/mcp', icon: Cpu, label: 'MCP 상태' },
{ path: '/account', icon: KeyRound, label: '내 계좌' },
]
return (

View File

@@ -17,6 +17,7 @@ import React from 'react'
import { Activity, TrendingUp, Clock } from 'lucide-react'
import { Position } from '../types/trading'
import { isUsStock } from '../utils/stockUtils'
import AccountSetupNotice from './AccountSetupNotice'
/**
* 가격 포맷 — 시장에 따라 원화 또는 달러 표시
@@ -44,6 +45,8 @@ interface PortfolioItemProps {
onRefetchAiAnalysis: () => void
/** 전량 청산(시장가 매도) 콜백 */
onSell: (stockCode: string, stockName: string, quantity: number) => Promise<void>
/** true면 청산 버튼 비활성화 (예: 사용자 모드 확정 전) */
sellDisabled?: boolean
}
/**
@@ -64,6 +67,7 @@ const PortfolioItem = React.memo(({
onSelectStock,
onRefetchAiAnalysis,
onSell,
sellDisabled,
}: PortfolioItemProps) => {
// 현재가: 실시간 가격 우선, 없으면 포트폴리오 API 가격 사용
const currentPrice = livePrices?.[stock.stock_code] ?? Number(stock.current_price || 0)
@@ -79,10 +83,13 @@ const PortfolioItem = React.memo(({
const handleSellClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
if (sellDisabled) return
try {
await onSell(stock.stock_code, stock.stock_name, quantity)
} catch (error: unknown) {
const err = error as any
// 계좌 미등록/미검증(400/423)은 Dashboard 상단 인라인 안내로 처리 — alert 생략
if (err?.isAccountSetupRequired) return
alert(`매도 실패: ${err.response?.data?.detail || err.message}`)
}
}
@@ -133,7 +140,8 @@ const PortfolioItem = React.memo(({
<div className="px-3 pb-2">
<button
onClick={handleSellClick}
className="w-full py-0.5 text-[9px] font-mono text-[#3a5570] hover:text-[#3d7eff] border border-[#152035] hover:border-[#3d7eff]/40 rounded-sm transition-colors"
disabled={sellDisabled}
className="w-full py-0.5 text-[9px] font-mono text-[#3a5570] hover:text-[#3d7eff] border border-[#152035] hover:border-[#3d7eff]/40 rounded-sm transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-[#3a5570] disabled:hover:border-[#152035]"
>
전량 청산 (시장가)
</button>
@@ -158,6 +166,12 @@ interface PortfolioPanelProps {
onRefetchAiAnalysis: () => void
/** 전량 청산 콜백 */
onSell: (stockCode: string, stockName: string, quantity: number) => Promise<void>
/** true면 청산 버튼 비활성화 (예: 사용자 모드 확정 전) */
sellDisabled?: boolean
/** true면 KIS 계좌 미등록/미검증 — 빈 목록 대신 계좌 설정 안내 표시 */
accountSetupRequired?: boolean
/** 계좌 미등록/미검증 안내 문구 (accountSetupRequired=true일 때만 사용) */
accountSetupMessage?: string
}
/**
@@ -174,6 +188,9 @@ const PortfolioPanel = React.memo(({
onSelectStock,
onRefetchAiAnalysis,
onSell,
sellDisabled,
accountSetupRequired,
accountSetupMessage,
}: PortfolioPanelProps) => {
const dayStocks = portfolio?.filter(s => s.strategy_type === 'daytrading') ?? []
const swingStocks = portfolio?.filter(s => s.strategy_type !== 'daytrading') ?? []
@@ -197,6 +214,7 @@ const PortfolioPanel = React.memo(({
onSelectStock={onSelectStock}
onRefetchAiAnalysis={onRefetchAiAnalysis}
onSell={onSell}
sellDisabled={sellDisabled}
/>
))}
</>
@@ -224,7 +242,11 @@ const PortfolioPanel = React.memo(({
{/* List */}
<div className="divide-y divide-[#0f1e2e] overflow-y-auto custom-scrollbar flex-1">
{total > 0 ? (
{accountSetupRequired ? (
<div className="h-full flex flex-col items-center justify-center p-3">
<AccountSetupNotice message={accountSetupMessage} compact />
</div>
) : total > 0 ? (
<>
{renderGroup(dayStocks, '데이트레이딩', <Clock className="w-2.5 h-2.5 text-[#f97316]" />, 'text-[#f97316]')}
{renderGroup(swingStocks, '스윙', <TrendingUp className="w-2.5 h-2.5 text-[#a78bfa]" />, 'text-[#a78bfa]')}

View File

@@ -13,6 +13,7 @@
*/
import { AlertTriangle, TrendingUp, TrendingDown, Wallet, BarChart3, Calendar, ArrowUpDown } from 'lucide-react';
import { PortfolioSummary as PortfolioSummaryType } from '../types/trading';
import AccountSetupNotice from './AccountSetupNotice';
/** 당일 매매 통계 인터페이스 (Dashboard의 useMemo에서 계산) */
interface TodayTradeStats {
@@ -38,6 +39,10 @@ interface PortfolioSummaryProps {
error: string | null | undefined;
/** 당일 매매 통계 */
todayTradeStats?: TodayTradeStats | null;
/** true면 KIS 계좌 미등록/미검증 — error/스켈레톤 대신 계좌 설정 안내 표시 */
accountSetupRequired?: boolean;
/** 계좌 미등록/미검증 안내 문구 (accountSetupRequired=true일 때만 사용) */
accountSetupMessage?: string;
}
/**
@@ -50,7 +55,11 @@ interface PortfolioSummaryProps {
* 에러 상태: 빨간 경고 배너 표시
* 로딩 상태: 스켈레톤 애니메이션 (animate-pulse)
*/
export const PortfolioSummary = ({ summary, error, todayTradeStats }: PortfolioSummaryProps) => {
export const PortfolioSummary = ({ summary, error, todayTradeStats, accountSetupRequired, accountSetupMessage }: PortfolioSummaryProps) => {
if (accountSetupRequired) {
return <AccountSetupNotice message={accountSetupMessage} />;
}
if (error) {
return (
<div className="flex items-center gap-2 text-red-400 text-xs px-3 py-1.5 bg-[#1a0808] border border-red-900/40 rounded mb-3">

View File

@@ -32,6 +32,8 @@ interface RecommendationItemProps {
isPending: boolean
livePrices: Record<string, number> | undefined
onBuy: (stockCode: string, stockName: string) => Promise<void>
/** true면 매수 버튼 비활성화 (예: 사용자 모드 확정 전) */
buyDisabled?: boolean
}
const RecommendationItem = React.memo(({
@@ -45,6 +47,7 @@ const RecommendationItem = React.memo(({
isPending,
livePrices,
onBuy,
buyDisabled,
}: RecommendationItemProps) => {
const changeRate = stock.change_rate || 0
const isSelected = selectedStock === stock.stock_code
@@ -53,13 +56,15 @@ const RecommendationItem = React.memo(({
const handleBuyClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
if (buying) return
if (buying || buyDisabled) return
if (!window.confirm(`${stock.stock_name} 시장가 매수할까요?\n(수량: 총자산 × 종목당 최대비중으로 자동 산출)`)) return
setBuying(true)
try {
await onBuy(stock.stock_code, stock.stock_name)
} catch (error: unknown) {
const err = error as any
// 계좌 미등록/미검증(400/423)은 Dashboard 상단 인라인 안내로 처리 — alert 생략
if (err?.isAccountSetupRequired) return
alert(`매수 실패: ${err.response?.data?.detail || err.message}`)
} finally {
setBuying(false)
@@ -135,7 +140,7 @@ const RecommendationItem = React.memo(({
<div className="px-3 pb-1.5 pl-8">
<button
onClick={handleBuyClick}
disabled={buying}
disabled={buying || buyDisabled}
className="w-full py-0.5 text-[9px] font-mono text-[#3a5570] hover:text-[#e84444] border border-[#152035] hover:border-[#e84444]/40 rounded-sm transition-colors disabled:opacity-50"
>
{buying ? '매수 주문 중...' : '매수 (시장가)'}
@@ -170,6 +175,8 @@ interface RecommendationPanelProps {
livePrices: Record<string, number> | undefined
/** 수동 매수 콜백 */
onBuy: (stockCode: string, stockName: string) => Promise<void>
/** true면 매수 버튼 비활성화 (예: 사용자 모드 확정 전) */
buyDisabled?: boolean
}
const RecommendationPanel = React.memo(({
@@ -183,6 +190,7 @@ const RecommendationPanel = React.memo(({
pendingBuyStockCodes,
livePrices,
onBuy,
buyDisabled,
}: RecommendationPanelProps) => {
return (
<div className="bg-[#0c1522] border border-[#152035] rounded overflow-hidden flex flex-col h-1/2">
@@ -242,6 +250,7 @@ const RecommendationPanel = React.memo(({
isPending={isPending}
livePrices={livePrices}
onBuy={onBuy}
buyDisabled={buyDisabled}
/>
)
})

View File

@@ -0,0 +1,63 @@
/**
* useAccountSetupStatus.ts — v2(로그인 세션) 사용자의 KIS 계좌 등록·검증 상태
*
* 배경:
* - `/portfolio/v2`, `/portfolio/v2/summary`(GET)는 KIS 계좌가 없어도 예외 없이
* 빈 값(0 잔고)을 반환한다(계좌 연결은 `get_user_context`를 쓰는 매수/매도/동기화
* 경로에서만 400/423으로 강제됨). 그대로 두면 "0 자산 대시보드"만 보이고
* 원인(계좌 미등록)을 알 수 없으므로, 기존 `/account/credentials` 조회로
* 선제적으로 상태를 판단해 화면에 안내한다.
* - legacy(API 키) 세션에서는 항상 required=false (계좌 개념이 전역 싱글톤이라 무관).
* - mode 미확정(undefined, `useTenantMode()` M5 참조) 동안은 v2인지 legacy인지 알 수
* 없으므로 required=false 단정 대신 isLoading=true로 보류한다.
*
* `queryKey: ['kis-credentials']`는 AccountSettings.tsx와 동일 — 캐시 공유.
*/
import { useQuery } from '@tanstack/react-query'
import { AxiosError } from 'axios'
import { getKisCredentials, KisCredentials } from '../lib/api'
import { useTenantMode } from '../lib/tenant'
export interface AccountSetupStatus {
/** true면 계좌 미등록/미검증 — 매수·매도·포트폴리오 화면에 안내 필요 */
required: boolean
message?: string
isLoading: boolean
}
export const useAccountSetupStatus = (): AccountSetupStatus => {
const { mode } = useTenantMode()
const { data, isLoading } = useQuery<KisCredentials | null>({
queryKey: ['kis-credentials'],
queryFn: async () => {
try {
return (await getKisCredentials()).data
} catch (err) {
if (err instanceof AxiosError && err.response?.status === 404) return null
throw err
}
},
enabled: mode === 'v2',
staleTime: 10_000,
})
if (mode === undefined) return { required: false, isLoading: true }
if (mode !== 'v2') return { required: false, isLoading: false }
if (isLoading) return { required: false, isLoading: true }
if (!data) {
return {
required: true,
message: '등록된 KIS 계좌가 없습니다. 내 계좌 설정에서 계좌를 등록하세요.',
isLoading: false,
}
}
if (!data.is_enabled) {
return {
required: true,
message: 'KIS 계좌가 아직 검증되지 않았습니다. 내 계좌 설정에서 연결 테스트를 진행하세요.',
isLoading: false,
}
}
return { required: false, isLoading: false }
}

View File

@@ -2,26 +2,44 @@
* usePortfolioData.ts — 포트폴리오 데이터 관리 커스텀 훅
*
* 역할:
* - /portfolio/combined API를 통해 보유 종목(holdings)과 요약(summary) 동시 조회
* - legacy(API 키 세션): /portfolio/combined API로 보유 종목(holdings)과 요약(summary) 동시 조회
* - v2(로그인 세션, 멀티테넌트): /portfolio/v2 + /portfolio/v2/summary를 병렬 조회해 동일 형태로 결합
* (v2에는 combined 엔드포인트가 없으므로 프론트에서 결합)
* - 30초 간격 자동 폴링으로 장중 포트폴리오 변동 감지
* - invalidatePortfolio()로 매매 체결 시 즉시 데이터 갱신 트리거
*
* 데이터 흐름:
* API /portfolio/combined → { holdings: Position[], summary: PortfolioSummary }
* → holdings에서 quantity > 0 필터 → portfolio(보유종목), portfolioSummary(요약)
* v2/legacy 분기는 `useTenantMode()`(lib/tenant.ts)로 판단하며, 이 훅의 시그니처·반환값은
* 기존 그대로 유지한다(호출부인 Dashboard.tsx 수정 최소화). 계좌 미등록·미검증(400) 응답은
* 예외를 던지는 대신 `accountSetupRequired`/`accountSetupMessage`로 노출해 화면에서
* 빈 상태 UI를 그릴 수 있게 한다.
*
* @returns portfolio, portfolioSummary, invalidatePortfolio 등
* 리뷰 M5: `mode`가 아직 확정되지 않은 동안(undefined)은 쿼리를 비활성화한다 — v2/legacy
* 확정 전에 legacy `/portfolio/combined`를 먼저 호출해 세션 사용자에게 owner 데이터가
* 노출되는 것을 방지한다.
*
* @returns portfolio, portfolioSummary, invalidatePortfolio, accountSetupRequired 등
*/
import { useQuery, useQueryClient } from '@tanstack/react-query'
import api from '../lib/api'
import { AxiosError } from 'axios'
import api, { getPortfolioV2, getPortfolioSummaryV2 } from '../lib/api'
import { Position, PortfolioSummary } from '../types/trading'
import { useTenantMode } from '../lib/tenant'
/** 포트폴리오 통합 데이터 인터페이스 */
export interface PortfolioCombinedData {
/** 보유 종목 배열 (quantity > 0 필터 적용) */
holdings: Position[]
/** 포트폴리오 요약 (총자산, 예수금, 손익 등) */
summary: PortfolioSummary
summary: PortfolioSummary | null
/** true면 계좌 미등록/미검증 — holdings/summary는 비어있는 상태로 취급 */
accountSetupRequired: boolean
/** 계좌 미등록/미검증 시 백엔드 detail 메시지 */
accountSetupMessage?: string
}
const isAccountSetupError = (err: unknown): err is AxiosError<{ detail?: string }> => {
const status = (err as AxiosError)?.response?.status
return status === 400 || status === 423
}
/**
@@ -34,6 +52,7 @@ export interface PortfolioCombinedData {
*/
export const usePortfolioData = () => {
const queryClient = useQueryClient()
const { mode } = useTenantMode()
const {
data: portfolioCombined,
@@ -41,8 +60,33 @@ export const usePortfolioData = () => {
isLoading: isPortfolioLoading,
refetch: refetchPortfolio,
} = useQuery({
queryKey: ['portfolio-combined'],
queryFn: async () => {
// mode별로 쿼리키를 분리해 v2/legacy 전환 시 이전 캐시가 섞이지 않도록 함
queryKey: ['portfolio-combined', mode],
queryFn: async (): Promise<PortfolioCombinedData> => {
if (mode === 'v2') {
try {
const [holdingsRes, summaryRes] = await Promise.all([
getPortfolioV2(),
getPortfolioSummaryV2(),
])
return {
holdings: (holdingsRes.data as Position[]).filter((item) => item.quantity > 0),
summary: summaryRes.data as PortfolioSummary,
accountSetupRequired: false,
}
} catch (err) {
if (isAccountSetupError(err)) {
return {
holdings: [],
summary: null,
accountSetupRequired: true,
accountSetupMessage: err.response?.data?.detail,
}
}
throw err
}
}
const data = (await api.get('/portfolio/combined')).data
return {
// quantity > 0인 종목만 보유 종목으로 처리 (매도 완료 종목 제외)
@@ -50,8 +94,10 @@ export const usePortfolioData = () => {
(item) => item.quantity > 0
),
summary: data.summary as PortfolioSummary,
accountSetupRequired: false,
}
},
enabled: mode !== undefined,
staleTime: 30000, // 30초 후 stale → 자동 리페치 허용
refetchInterval: 30000, // 장중 30초 폴링 (매매 감지 보조)
refetchOnWindowFocus: true, // 탭 전환 시 즉시 갱신
@@ -60,7 +106,9 @@ export const usePortfolioData = () => {
/** 보유 종목 배열 */
const portfolio = portfolioCombined?.holdings
/** 포트폴리오 요약 데이터 */
const portfolioSummary = portfolioCombined?.summary
const portfolioSummary = portfolioCombined?.summary ?? undefined
const accountSetupRequired = portfolioCombined?.accountSetupRequired ?? false
const accountSetupMessage = portfolioCombined?.accountSetupMessage
/**
* 포트폴리오 캐시 무효화 — 매매 체결 감지 시 즉시 호출
@@ -78,5 +126,7 @@ export const usePortfolioData = () => {
portfolioSummaryError,
refetchPortfolio,
invalidatePortfolio,
accountSetupRequired,
accountSetupMessage,
}
}

View File

@@ -1,9 +1,51 @@
/**
* useTradeActions.ts — 수동 매수/매도 액션 훅
*
* v2/legacy 분기:
* - v2(로그인 세션): `/portfolio/v2/buy`, `/portfolio/v2/sell`
* - legacy(API 키 세션, MULTI_TENANT_ENABLED=false 포함): 기존 `/portfolio/buy`, `/portfolio/sell`
* 훅 시그니처(handleBuy/handleSell 파라미터·반환 타입)는 그대로 유지하고 내부 구현만
* `useTenantMode()`로 분기한다 — Dashboard.tsx 등 호출부 수정 없이 자동 전환.
*
* 계좌 미등록/미검증(400/423) 에러는 `err.isAccountSetupRequired = true`로 표시해
* 호출부가 alert 대신 인라인 안내(AccountSetupNotice)를 띄울 수 있게 한다.
*
* 리뷰 M5/M6:
* - `mode`가 아직 확정되지 않은 동안(undefined)은 v2/legacy 어느 쪽으로도 주문을 내지
* 않도록 매수/매도/미체결정리 모두 즉시 거부한다(`isReady=false`). 호출부는
* `isReady`로 버튼을 disabled 처리해야 한다.
* - `resetPendingTrades`는 legacy(`DELETE /trades/pending`)가 사용자 무관 전체 삭제이므로
* v2 모드에서는 사용자 스코프 `DELETE /trades/v2/pending`으로 분기한다.
*/
import { useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import api from '../lib/api'
import { AxiosError } from 'axios'
import api, { buyStockV2, sellStockV2, resetPendingTradesV2 } from '../lib/api'
import { useTenantMode } from '../lib/tenant'
/** 계좌 미등록/미검증(400/423) 여부를 표시하는 확장 에러 타입 */
export type TradeActionError = AxiosError<{ detail?: string }> & { isAccountSetupRequired?: boolean }
const markAccountSetupError = (error: unknown): TradeActionError => {
const err = error as TradeActionError
const status = err?.response?.status
if (status === 400 || status === 423) {
err.isAccountSetupRequired = true
}
return err
}
/** mode 미확정 상태에서 호출됐을 때 던지는 에러 — 계좌 설정 안내가 아니라 "아직 준비 안 됨" */
class TenantModeNotReadyError extends Error {
constructor() {
super('사용자 모드를 확인하는 중입니다. 잠시 후 다시 시도하세요.')
}
}
export const useTradeActions = () => {
const queryClient = useQueryClient()
const { mode } = useTenantMode()
const isReady = mode !== undefined
const handleSell = useCallback(
async (
@@ -11,14 +53,16 @@ export const useTradeActions = () => {
stockName: string,
quantity: number
): Promise<void> => {
if (mode === undefined) throw new TenantModeNotReadyError()
try {
// KIS 모의서버 지연 시 주문 1건에 30~60초 걸릴 수 있음 — 기본 15초로 끊으면
// 서버는 체결하는데 화면은 실패로 보여 중복 주문을 유발함
await api.post('/portfolio/sell', {
stock_code: stockCode,
quantity,
price: null,
}, { timeout: 90000 })
const body = { stock_code: stockCode, quantity, price: null }
if (mode === 'v2') {
await sellStockV2(body)
} else {
await api.post('/portfolio/sell', body, { timeout: 90000 })
}
// Invalidate queries to refresh portfolio
queryClient.invalidateQueries({
@@ -29,44 +73,50 @@ export const useTradeActions = () => {
})
console.log(`✅ ${stockName} sell order completed`)
} catch (error: any) {
} catch (error: unknown) {
const err = markAccountSetupError(error)
console.error(
`❌ Sell order failed for ${stockName}:`,
error.response?.data?.detail || error.message
err.response?.data?.detail || err.message
)
throw error
throw err
}
},
[queryClient]
[queryClient, mode]
)
/** 추천 종목 수동 매수 (시장가, 수량은 서버가 예산 기반 산출) */
const handleBuy = useCallback(
async (stockCode: string, stockName: string): Promise<void> => {
if (mode === undefined) throw new TenantModeNotReadyError()
try {
await api.post('/portfolio/buy', {
stock_code: stockCode,
stock_name: stockName,
quantity: null,
price: null,
}, { timeout: 90000 })
const body = { stock_code: stockCode, stock_name: stockName, quantity: null, price: null }
if (mode === 'v2') {
await buyStockV2(body)
} else {
await api.post('/portfolio/buy', body, { timeout: 90000 })
}
queryClient.invalidateQueries({ queryKey: ['portfolio-combined'] })
queryClient.invalidateQueries({ queryKey: ['trade-history'] })
console.log(`✅ ${stockName} buy order completed`)
} catch (error: any) {
} catch (error: unknown) {
const err = markAccountSetupError(error)
console.error(
`❌ Buy order failed for ${stockName}:`,
error.response?.data?.detail || error.message
err.response?.data?.detail || err.message
)
throw error
throw err
}
},
[queryClient]
[queryClient, mode]
)
const resetPendingTrades = useCallback(async (): Promise<void> => {
if (mode === undefined) throw new TenantModeNotReadyError()
try {
const response = await api.delete('/trades/pending')
const response = mode === 'v2'
? await resetPendingTradesV2()
: await api.delete('/trades/pending')
queryClient.invalidateQueries({
queryKey: ['trade-history'],
})
@@ -78,7 +128,7 @@ export const useTradeActions = () => {
)
throw error
}
}, [queryClient])
}, [queryClient, mode])
const invalidateTradeHistory = useCallback(() => {
queryClient.invalidateQueries({
@@ -91,5 +141,7 @@ export const useTradeActions = () => {
handleBuy,
resetPendingTrades,
invalidateTradeHistory,
/** false면 mode 미확정 — 매수/매도/미체결정리 버튼을 disabled 처리해야 함 */
isReady,
}
}

View File

@@ -176,4 +176,76 @@ export const getUsStockPrice = (ticker: string) =>
export const getUsStockChart = (ticker: string, period: string = 'D') =>
api.get(`/market-data/overseas/chart/${ticker}`, { params: { period } })
// ── 사용자 스코프 v2 API (멀티테넌트 1단계, D그룹) ─────────────────────────
// 응답 스키마는 기존 v1(`/portfolio`, `/trades`)과 동일 필드. 호출자는
// `lib/tenant.ts`의 `useTenantMode()`로 v2/legacy 여부를 판단해 골라 쓴다.
// 423 = 사용자 자동매매 미활성(계좌 미검증), 400 = KIS 계좌 미등록·미검증
// (둘 다 "내 계좌 설정에서 계좌를 등록·검증하세요" 안내 대상).
export interface ManualTradeRequest {
stock_code: string
stock_name?: string
quantity?: number | null
price?: number | null
}
export const getPortfolioV2 = () => api.get('/portfolio/v2')
export const getPortfolioSummaryV2 = () => api.get('/portfolio/v2/summary')
export const syncPortfolioV2 = () => api.post('/portfolio/v2/sync')
export const buyStockV2 = (body: ManualTradeRequest) =>
api.post('/portfolio/v2/buy', body, { timeout: 90000 })
export const sellStockV2 = (body: ManualTradeRequest) =>
api.post('/portfolio/v2/sell', body, { timeout: 90000 })
export const getTradesV2 = (params?: Record<string, unknown>) =>
api.get('/trades/v2', { params })
// M6: legacy `/trades/pending`는 user_id 조건 없이 전체 PENDING/FAILED 이력을 삭제하므로
// v2(로그인 세션) 모드에서는 사용자 스코프 삭제 엔드포인트를 별도로 사용한다.
export const resetPendingTradesV2 = () => api.delete('/trades/v2/pending')
// ── 내 계좌 설정 (KIS 자격증명) ─────────────────────────────────────────────────
export interface KisCredentials {
mode: 'virtual' | 'real'
app_key_hint: string
account_masked: string
is_enabled: boolean
auto_trade_enabled: boolean
last_verified_at: string | null
updated_at: string
}
export interface KisCredentialsInput {
mode: 'virtual' | 'real'
app_key: string
app_secret: string
account_no: string
}
export interface KisVerifyResult {
ok: boolean
balance_summary?: Record<string, unknown>
verified_at: string
}
export const getKisCredentials = () =>
api.get<KisCredentials>('/account/credentials')
export const putKisCredentials = (body: KisCredentialsInput) =>
api.put<KisCredentials>('/account/credentials', body)
export const deleteKisCredentials = () =>
api.delete<void>('/account/credentials')
export const verifyKisCredentials = () =>
api.post<KisVerifyResult>('/account/credentials/verify')
export const patchAutoTrade = (enabled: boolean) =>
api.patch<KisCredentials>('/account/auto-trade', { enabled })
export default api

View File

@@ -0,0 +1,52 @@
/**
* tenant.ts — 멀티테넌트 v2/legacy 전환 스위치
*
* 역할:
* - `GET /auth/me`로 호출 주체(auth_type)를 확인해 포트폴리오·거래이력·매수/매도가
* 사용자 스코프 v2 API(`/portfolio/v2`, `/trades/v2`)를 쓸지, 기존 v1 API를
* 쓸지 결정한다.
* - auth_type === 'session' (로그인 사용자) → v2
* - auth_type === 'api_key' (레거시 운영자 API 키, MULTI_TENANT_ENABLED=false 환경 포함)
* → legacy(v1). 백엔드도 이 경우 `/portfolio/v2/sync`를 스킵 응답하므로 v1 그대로 사용해야 한다.
*
* 캐시: staleTime 5분 — 세션 타입은 로그인 상태에서 바뀌지 않으므로 자주 재조회할 필요 없음.
*
* 리뷰 M5 대응: `/auth/me` 응답이 오기 전에 'legacy'를 반환하면 세션(로그인) 사용자에게
* 잠깐 owner(legacy) 데이터가 노출되고 그 사이 매수/매도가 legacy 계좌로 나갈 수 있다.
* 따라서 결정 전(isLoading) 및 조회 실패(401 등) 시에는 `undefined`를 반환해 호출부가
* 데이터 조회·매매 액션을 모두 보류하도록 한다. 401은 기존 axios 응답 인터셉터가
* `api_key`를 지우고 새로고침(→ 로그인 화면)하므로 별도 처리가 필요 없다.
*/
import { useQuery } from '@tanstack/react-query'
import api from './api'
export interface AuthMe {
username: string
auth_type: 'session' | 'api_key'
is_admin: boolean
}
/** 'v2' | 'legacy' 확정 전(로딩 중·조회 실패)에는 undefined */
export type TenantMode = 'v2' | 'legacy' | undefined
/**
* useTenantMode — 현재 호출 주체의 v2/legacy 모드를 반환
*
* `/auth/me` 조회가 끝나기 전(isLoading)이거나 실패(error)한 동안에는 모드를
* 확정할 수 없으므로 `undefined`를 반환한다. 호출부는 `mode !== undefined`를
* react-query `enabled` 조건과 매수/매도 버튼 disabled 조건으로 사용해야 한다.
*/
export const useTenantMode = () => {
const { data: me, isLoading, error } = useQuery<AuthMe>({
queryKey: ['auth-me'],
queryFn: async () => (await api.get('/auth/me')).data,
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
})
const mode: TenantMode = isLoading || error || !me
? undefined
: me.auth_type === 'session' ? 'v2' : 'legacy'
return { mode, me, isLoading, error }
}

View File

@@ -0,0 +1,525 @@
/**
* AccountSettings.tsx — 내 계좌 설정 페이지
*
* 역할:
* - 사용자별 KIS(한국투자증권) 자격증명 등록/교체/삭제
* - 연결 테스트(KIS 인증 확인) 및 자동매매 활성화 토글
*
* 데이터 흐름:
* - react-query: /account/credentials (조회), /auth/me (세션 타입 확인)
* - mutation: PUT(등록/교체), DELETE(삭제), POST verify(연결 테스트), PATCH auto-trade(토글)
*
* 주의:
* - 실전(real) 계좌 등록 시 확인 모달 필수 (실제 주문 발생)
* - 자격증명은 마스킹된 형태로만 표시 (평문 재노출 없음)
*/
import { useEffect, useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { AxiosError } from 'axios'
import {
KeyRound,
ShieldAlert,
ShieldCheck,
Eye,
EyeOff,
Trash2,
Loader2,
CheckCircle2,
XCircle,
Info,
} from 'lucide-react'
import api, {
getKisCredentials,
putKisCredentials,
deleteKisCredentials,
verifyKisCredentials,
patchAutoTrade,
KisCredentials,
KisCredentialsInput,
} from '../lib/api'
/** 계좌번호 표시용 정규화: 숫자만 남긴 뒤 8-2 형태로 하이픈 삽입 */
const formatAccountInput = (raw: string) => {
const digits = raw.replace(/[^0-9]/g, '').slice(0, 10)
if (digits.length <= 8) return digits
return `${digits.slice(0, 8)}-${digits.slice(8)}`
}
interface AuthMe {
username: string
auth_type: 'session' | 'api_key'
is_admin: boolean
}
const AccountSettings = () => {
const queryClient = useQueryClient()
const [showAppKey, setShowAppKey] = useState(false)
const [showAppSecret, setShowAppSecret] = useState(false)
const [mode, setMode] = useState<'virtual' | 'real'>('virtual')
const [appKey, setAppKey] = useState('')
const [appSecret, setAppSecret] = useState('')
const [accountNo, setAccountNo] = useState('')
const [formError, setFormError] = useState<string | null>(null)
const [showRealConfirm, setShowRealConfirm] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [verifyBanner, setVerifyBanner] = useState<
{ type: 'success' | 'error'; msg: string; balance?: Record<string, unknown> } | null
>(null)
const [cooldownSec, setCooldownSec] = useState(0)
// 60초 연결 테스트 쿨다운 카운트다운
useEffect(() => {
if (cooldownSec <= 0) return
const t = setInterval(() => setCooldownSec((s) => Math.max(0, s - 1)), 1000)
return () => clearInterval(t)
}, [cooldownSec])
const { data: me } = useQuery<AuthMe>({
queryKey: ['auth-me'],
queryFn: async () => (await api.get('/auth/me')).data,
staleTime: 60_000,
})
const {
data: credentials,
isLoading,
isError,
} = useQuery<KisCredentials | null>({
queryKey: ['kis-credentials'],
queryFn: async () => {
try {
return (await getKisCredentials()).data
} catch (err) {
if (err instanceof AxiosError && err.response?.status === 404) return null
throw err
}
},
staleTime: 10_000,
})
const isRegistered = !!credentials
const saveMutation = useMutation({
mutationFn: (body: KisCredentialsInput) => putKisCredentials(body),
onSuccess: (res) => {
queryClient.setQueryData(['kis-credentials'], res.data)
setAppKey('')
setAppSecret('')
setAccountNo('')
setFormError(null)
setVerifyBanner(null)
},
onError: (err: AxiosError<any>) => {
const status = err.response?.status
if (status === 409) {
setFormError('이미 다른 사용자가 등록한 앱키입니다. 다른 앱키를 사용하세요.')
} else if (status === 422) {
setFormError(err.response?.data?.detail ?? '입력 형식을 확인하세요 (계좌번호는 10자리 숫자).')
} else if (status === 503) {
setFormError('서버 암호화 설정이 되어 있지 않습니다. 관리자에게 문의하세요.')
} else {
setFormError(err.response?.data?.detail ?? '저장에 실패했습니다.')
}
},
})
const deleteMutation = useMutation({
mutationFn: () => deleteKisCredentials(),
onSuccess: () => {
queryClient.setQueryData(['kis-credentials'], null)
setShowDeleteConfirm(false)
setVerifyBanner(null)
},
})
const verifyMutation = useMutation({
mutationFn: () => verifyKisCredentials(),
onSuccess: (res) => {
setVerifyBanner({ type: 'success', msg: '연결 확인 성공', balance: res.data.balance_summary })
queryClient.invalidateQueries({ queryKey: ['kis-credentials'] })
},
onError: (err: AxiosError<any>) => {
if (err.response?.status === 429) {
const detail = err.response?.data?.detail ?? '잠시 후 다시 시도하세요.'
setVerifyBanner({ type: 'error', msg: detail })
setCooldownSec(60)
} else {
setVerifyBanner({
type: 'error',
msg: err.response?.data?.detail ?? 'KIS 인증에 실패했습니다.',
})
}
},
})
const autoTradeMutation = useMutation({
mutationFn: (enabled: boolean) => patchAutoTrade(enabled),
onSuccess: (res) => {
queryClient.setQueryData(['kis-credentials'], res.data)
},
onError: (err: AxiosError<any>) => {
setFormError(err.response?.data?.detail ?? '자동매매 설정 변경에 실패했습니다.')
},
})
const submitCredentials = () => {
setFormError(null)
const digits = accountNo.replace(/[^0-9]/g, '')
if (!appKey.trim() || !appSecret.trim() || digits.length !== 10) {
setFormError('앱키·앱시크릿·계좌번호(10자리)를 모두 입력하세요.')
return
}
const body: KisCredentialsInput = { mode, app_key: appKey.trim(), app_secret: appSecret.trim(), account_no: accountNo }
if (mode === 'real') {
setShowRealConfirm(true)
return
}
saveMutation.mutate(body)
}
const confirmRealSubmit = () => {
setShowRealConfirm(false)
saveMutation.mutate({ mode, app_key: appKey.trim(), app_secret: appSecret.trim(), account_no: accountNo })
}
const isApiKeySession = me?.auth_type === 'api_key'
return (
<div className="min-h-full bg-[#0f1115] px-6 py-8">
<div className="max-w-3xl mx-auto space-y-6">
{/* 헤더 */}
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<h1 className="text-2xl font-semibold text-gray-100 flex items-center gap-2">
<KeyRound size={22} className="text-blue-400" />
내 계좌 설정
</h1>
<p className="text-sm text-gray-500 mt-1">
자동매매에 사용할 한국투자증권(KIS) 계좌 자격증명을 등록·관리합니다.
</p>
{!isApiKeySession && me && (
<p className="text-xs text-blue-400/80 mt-1">
대시보드·포트폴리오·매매는 여기 등록한 내 계좌 기준으로 표시됩니다.
</p>
)}
</div>
{isApiKeySession && (
<span className="text-xs px-3 py-1.5 rounded-full border border-yellow-500/40 bg-yellow-500/10 text-yellow-400">
운영자(API 키) 세션 — legacy 계좌
</span>
)}
</div>
{/* 상태 카드 */}
<div className="bg-[#161b22] border border-gray-800 rounded-xl p-5">
{isLoading ? (
<div className="flex items-center gap-2 text-gray-500 text-sm py-4">
<Loader2 size={16} className="animate-spin" />
불러오는 중...
</div>
) : isError ? (
<div className="flex items-center gap-2 text-red-400 text-sm py-4" role="alert">
<XCircle size={16} />
상태를 불러오지 못했습니다.
</div>
) : !isRegistered ? (
<div className="flex items-center gap-3 text-gray-400 text-sm py-4">
<Info size={18} className="text-gray-500 shrink-0" />
등록된 계좌가 없습니다. 아래 폼에서 계좌를 등록하세요.
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<span
className={`text-xs font-semibold px-2.5 py-1 rounded-full border ${
credentials!.mode === 'real'
? 'bg-red-500/15 text-red-400 border-red-500/50'
: 'bg-blue-500/15 text-blue-400 border-blue-500/40'
}`}
>
{credentials!.mode === 'real' ? '실전투자' : '모의투자'}
</span>
<span className="text-sm text-gray-300 font-mono">
{credentials!.app_key_hint}…
</span>
<span className="text-sm text-gray-500 font-mono">
{credentials!.account_masked}
</span>
{credentials!.is_enabled ? (
<span className="flex items-center gap-1 text-xs text-green-400">
<ShieldCheck size={14} /> 검증됨
</span>
) : (
<span className="flex items-center gap-1 text-xs text-yellow-400">
<ShieldAlert size={14} /> 미검증
</span>
)}
</div>
<button
onClick={() => setShowDeleteConfirm(true)}
className="flex items-center gap-1.5 text-xs text-red-400 border border-red-500/40 rounded-lg px-3 py-1.5 hover:bg-red-500/10 transition-colors"
>
<Trash2 size={13} />
삭제
</button>
</div>
<div className="text-xs text-gray-600">
마지막 검증:{' '}
{credentials!.last_verified_at
? new Date(credentials!.last_verified_at).toLocaleString('ko-KR')
: '없음'}
</div>
{/* 연결 테스트 */}
<div className="flex items-center gap-3 flex-wrap">
<button
onClick={() => verifyMutation.mutate()}
disabled={verifyMutation.isPending || cooldownSec > 0}
className="flex items-center gap-2 text-sm px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
>
{verifyMutation.isPending && <Loader2 size={14} className="animate-spin" />}
연결 테스트
{cooldownSec > 0 && ` (${cooldownSec}초 후 가능)`}
</button>
{/* 자동매매 토글 */}
<div className="flex items-center gap-2">
<button
onClick={() => autoTradeMutation.mutate(!credentials!.auto_trade_enabled)}
disabled={!credentials!.is_enabled || autoTradeMutation.isPending}
title={!credentials!.is_enabled ? '연결 테스트를 먼저 통과해야 합니다.' : undefined}
className={`relative w-11 h-6 rounded-full transition-colors shrink-0 disabled:opacity-30 disabled:cursor-not-allowed ${
credentials!.auto_trade_enabled ? 'bg-blue-500' : 'bg-gray-700'
}`}
>
<span
className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${
credentials!.auto_trade_enabled ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</button>
<span className="text-sm text-gray-300">자동매매</span>
{!credentials!.is_enabled && (
<span className="text-xs text-gray-600">(연결 테스트 필요)</span>
)}
</div>
</div>
{verifyBanner && (
<div
role="alert"
className={`flex items-start gap-2 text-sm px-3 py-2.5 rounded-lg border ${
verifyBanner.type === 'success'
? 'bg-green-500/10 border-green-500/30 text-green-400'
: 'bg-red-500/10 border-red-500/30 text-red-400'
}`}
>
{verifyBanner.type === 'success' ? (
<CheckCircle2 size={16} className="mt-0.5 shrink-0" />
) : (
<XCircle size={16} className="mt-0.5 shrink-0" />
)}
<div>
<p>{verifyBanner.msg}</p>
{verifyBanner.balance && (
<pre className="mt-1.5 text-xs text-gray-400 whitespace-pre-wrap font-mono">
{JSON.stringify(verifyBanner.balance, null, 2)}
</pre>
)}
</div>
</div>
)}
</div>
)}
</div>
{/* 등록/교체 폼 */}
<div className="bg-[#161b22] border border-gray-800 rounded-xl p-5 space-y-4">
<h2 className="text-sm font-semibold text-gray-200">
{isRegistered ? '계좌 교체' : '계좌 등록'}
</h2>
{/* 모드 라디오 */}
<fieldset>
<legend className="text-xs text-gray-500 mb-2">투자 모드</legend>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input
type="radio"
name="kis-mode"
value="virtual"
checked={mode === 'virtual'}
onChange={() => setMode('virtual')}
className="accent-blue-500"
/>
모의투자
</label>
<label className="flex items-center gap-2 text-sm text-red-400 cursor-pointer">
<input
type="radio"
name="kis-mode"
value="real"
checked={mode === 'real'}
onChange={() => setMode('real')}
className="accent-red-500"
/>
실전투자
</label>
</div>
</fieldset>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="app-key" className="block text-xs text-gray-500 mb-1.5">
앱키 (App Key)
</label>
<div className="relative">
<input
id="app-key"
type={showAppKey ? 'text' : 'password'}
value={appKey}
onChange={(e) => setAppKey(e.target.value)}
aria-invalid={!!formError}
className="w-full px-3 py-2 pr-9 text-sm bg-gray-900 border border-gray-700 rounded-lg text-gray-100 focus:outline-none focus:border-blue-500"
placeholder="PS로 시작하는 앱키"
autoComplete="off"
/>
<button
type="button"
onClick={() => setShowAppKey((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
aria-label={showAppKey ? '앱키 숨기기' : '앱키 표시'}
>
{showAppKey ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
</div>
</div>
<div>
<label htmlFor="app-secret" className="block text-xs text-gray-500 mb-1.5">
앱시크릿 (App Secret)
</label>
<div className="relative">
<input
id="app-secret"
type={showAppSecret ? 'text' : 'password'}
value={appSecret}
onChange={(e) => setAppSecret(e.target.value)}
aria-invalid={!!formError}
className="w-full px-3 py-2 pr-9 text-sm bg-gray-900 border border-gray-700 rounded-lg text-gray-100 focus:outline-none focus:border-blue-500"
placeholder="앱시크릿"
autoComplete="off"
/>
<button
type="button"
onClick={() => setShowAppSecret((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
aria-label={showAppSecret ? '앱시크릿 숨기기' : '앱시크릿 표시'}
>
{showAppSecret ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
</div>
</div>
</div>
<div>
<label htmlFor="account-no" className="block text-xs text-gray-500 mb-1.5">
계좌번호
</label>
<input
id="account-no"
type="text"
inputMode="numeric"
value={accountNo}
onChange={(e) => setAccountNo(formatAccountInput(e.target.value))}
aria-invalid={!!formError}
className="w-full sm:w-56 px-3 py-2 text-sm bg-gray-900 border border-gray-700 rounded-lg text-gray-100 font-mono focus:outline-none focus:border-blue-500"
placeholder="12345678-01"
/>
</div>
{formError && (
<div role="alert" className="text-sm text-red-400 bg-red-500/10 border border-red-500/30 rounded-lg px-3 py-2">
{formError}
</div>
)}
<button
onClick={submitCredentials}
disabled={saveMutation.isPending}
className="flex items-center gap-2 text-sm px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white rounded-lg transition-colors"
>
{saveMutation.isPending && <Loader2 size={14} className="animate-spin" />}
저장
</button>
</div>
</div>
{/* 실전 계좌 확인 모달 */}
{showRealConfirm && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-[#161b22] border border-red-500/40 rounded-xl p-6 max-w-sm w-full space-y-4">
<div className="flex items-center gap-2 text-red-400">
<ShieldAlert size={20} />
<h3 className="font-semibold">실전 계좌 등록 확인</h3>
</div>
<p className="text-sm text-gray-300">
실전 계좌로 등록하면 자동매매 활성화 시 <strong className="text-red-400">실제 주문</strong>이
체결됩니다. 계속하시겠습니까?
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowRealConfirm(false)}
className="px-4 py-2 text-sm text-gray-400 hover:text-white border border-gray-700 rounded-lg"
>
취소
</button>
<button
onClick={confirmRealSubmit}
className="px-4 py-2 text-sm bg-red-600 hover:bg-red-500 text-white rounded-lg"
>
실전 계좌로 등록
</button>
</div>
</div>
</div>
)}
{/* 삭제 확인 모달 */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-[#161b22] border border-gray-700 rounded-xl p-6 max-w-sm w-full space-y-4">
<div className="flex items-center gap-2 text-gray-200">
<Trash2 size={18} className="text-red-400" />
<h3 className="font-semibold">계좌 삭제 확인</h3>
</div>
<p className="text-sm text-gray-300">
등록된 KIS 계좌 자격증명을 삭제합니다. 자동매매가 즉시 비활성화됩니다.
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 text-sm text-gray-400 hover:text-white border border-gray-700 rounded-lg"
>
취소
</button>
<button
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
className="flex items-center gap-2 px-4 py-2 text-sm bg-red-600 hover:bg-red-500 disabled:opacity-40 text-white rounded-lg"
>
{deleteMutation.isPending && <Loader2 size={14} className="animate-spin" />}
삭제
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default AccountSettings

View File

@@ -25,13 +25,17 @@ import api from '../lib/api'
import { useWebSocket } from '../hooks/useWebSocket'
import { usePortfolioData } from '../hooks/usePortfolioData'
import { useMarketData } from '../hooks/useMarketData'
import { useTradeActions } from '../hooks/useTradeActions'
import { useTradeActions, TradeActionError } from '../hooks/useTradeActions'
import { useAccountSetupStatus } from '../hooks/useAccountSetupStatus'
import { useTenantMode } from '../lib/tenant'
import { getTradesV2 } from '../lib/api'
import { PortfolioSummary } from '../components/PortfolioSummary'
import PortfolioPanel from '../components/PortfolioPanel'
import ChartPanel, { AiAnalysisPanel } from '../components/ChartPanel'
import OrderbookPanel from '../components/OrderbookPanel'
import RecommendationPanel from '../components/RecommendationPanel'
import JournalPanel from '../components/JournalPanel'
import AccountSetupNotice from '../components/AccountSetupNotice'
import { CandleData, Position, TradeHistory } from '../types/trading'
import { isUsStock } from '../utils/stockUtils'
import './Dashboard.css'
@@ -123,8 +127,16 @@ const Dashboard = () => {
portfolioSummary,
portfolioSummaryError,
invalidatePortfolio,
accountSetupRequired,
accountSetupMessage,
} = usePortfolioData()
// v2(로그인 세션) 사용자의 KIS 계좌 등록/검증 상태 — 매수/매도 시도 전 선제 안내용
const accountStatus = useAccountSetupStatus()
const { mode: tenantMode } = useTenantMode()
// 매수/매도 액션 중 계좌 미등록/미검증(400/423)을 받으면 배너로 노출 (alert 대체)
const [tradeAccountNotice, setTradeAccountNotice] = useState<string | null>(null)
// Market data hook (with throttled live prices and market update handler)
const {
livePrices,
@@ -210,7 +222,42 @@ const Dashboard = () => {
}, [processChartData])
// Trade actions hook
const { handleSell, handleBuy, resetPendingTrades } = useTradeActions()
// isReady=false(useTenantMode 확정 전)면 매수/매도/미체결정리 버튼을 모두 disabled 처리
const { handleSell, handleBuy, resetPendingTrades, isReady: tradeActionsReady } = useTradeActions()
// 계좌 미등록/미검증(400/423)은 상단 배너로 흡수하고, 그 외 에러는 그대로 전파해
// 각 패널의 기존 alert 처리(다른 실패 사유)가 계속 동작하도록 한다.
const handleBuyGuarded = useCallback(
async (stockCode: string, stockName: string) => {
try {
await handleBuy(stockCode, stockName)
} catch (error: unknown) {
const err = error as TradeActionError
if (err.isAccountSetupRequired) {
setTradeAccountNotice(err.response?.data?.detail || '자동매매가 비활성화 상태입니다. 내 계좌 설정에서 계좌를 등록·검증하세요.')
return
}
throw error
}
},
[handleBuy]
)
const handleSellGuarded = useCallback(
async (stockCode: string, stockName: string, quantity: number) => {
try {
await handleSell(stockCode, stockName, quantity)
} catch (error: unknown) {
const err = error as TradeActionError
if (err.isAccountSetupRequired) {
setTradeAccountNotice(err.response?.data?.detail || '자동매매가 비활성화 상태입니다. 내 계좌 설정에서 계좌를 등록·검증하세요.')
return
}
throw error
}
},
[handleSell]
)
// Subscribe to market data via WebSocket
// reconnect 감지: wsConnected가 false→true 전환 시 강제 재구독
@@ -352,10 +399,13 @@ const Dashboard = () => {
// 거래 이력 - 30초 (자동매매 체결 감지용)
const { data: tradeHistory } = useQuery({
queryKey: ['trade-history'],
queryKey: ['trade-history', tenantMode],
queryFn: async () => {
const data = (await api.get('/trades', { params: { limit: 50 } }))
.data as TradeHistory[]
const data = (
tenantMode === 'v2'
? await getTradesV2({ limit: 50 })
: await api.get('/trades', { params: { limit: 50 } })
).data as TradeHistory[]
const seen = new Set<number>()
return data
@@ -366,6 +416,8 @@ const Dashboard = () => {
})
.slice(0, 20)
},
// M5: mode 미확정(undefined) 동안은 v2/legacy 어느 쪽으로도 조회하지 않음
enabled: tenantMode !== undefined,
staleTime: 0,
refetchInterval: isMarketOpen ? 30000 : false,
refetchOnWindowFocus: true,
@@ -719,7 +771,16 @@ const Dashboard = () => {
))}
</div>
</div>
<PortfolioSummary summary={liveSummary} error={portfolioSummaryError?.message ?? null} todayTradeStats={todayTradeStats} />
{tradeAccountNotice && (
<AccountSetupNotice message={tradeAccountNotice} className="mb-2" />
)}
<PortfolioSummary
summary={liveSummary}
error={portfolioSummaryError?.message ?? null}
todayTradeStats={todayTradeStats}
accountSetupRequired={accountSetupRequired || accountStatus.required}
accountSetupMessage={accountSetupMessage || accountStatus.message}
/>
</div>
<div className="grid grid-cols-12 gap-3 flex-1 min-h-0">
@@ -732,7 +793,10 @@ const Dashboard = () => {
selectedStock={selectedStock}
onSelectStock={handleSelectStock}
onRefetchAiAnalysis={stableRefetchAiAnalysis}
onSell={handleSell}
onSell={handleSellGuarded}
sellDisabled={!tradeActionsReady}
accountSetupRequired={accountSetupRequired || accountStatus.required}
accountSetupMessage={accountSetupMessage || accountStatus.message}
/>
</div>
@@ -770,12 +834,14 @@ const Dashboard = () => {
heldStockCodes={heldStockCodes}
pendingBuyStockCodes={pendingBuyStockCodes}
livePrices={livePrices}
onBuy={handleBuy}
onBuy={handleBuyGuarded}
buyDisabled={!tradeActionsReady}
/>
<JournalPanel
journals={journals}
tradeHistory={tradeHistory}
onResetPending={resetPendingTrades}
resetDisabled={!tradeActionsReady}
/>
</div>
</div>

View File

@@ -3,7 +3,8 @@ import { format, differenceInMinutes } from 'date-fns'
import { ko } from 'date-fns/locale'
import { AlertCircle, List, BarChart2 } from 'lucide-react'
import HtsPage from '../components/HtsPage'
import api from '../lib/api'
import api, { getTradesV2 } from '../lib/api'
import { useTenantMode } from '../lib/tenant'
import { useState, Fragment } from 'react'
/** DB에서 KST로 저장된 datetime 문자열을 Date 객체로 변환
@@ -338,10 +339,18 @@ const TradeHistory = () => {
const today = format(new Date(), 'yyyy-MM-dd')
const [dateFrom, setDateFrom] = useState<string>(today)
const [dateTo, setDateTo] = useState<string>(today)
const { mode: tenantMode } = useTenantMode()
const { data: trades = [], isLoading, isError } = useQuery<Trade[]>({
queryKey: ['trade-history'],
queryFn: async () => (await api.get('/trades', { params: { limit: 200, days: 30 } })).data,
queryKey: ['trade-history', tenantMode],
queryFn: async () =>
(
tenantMode === 'v2'
? await getTradesV2({ limit: 200, days: 30 })
: await api.get('/trades', { params: { limit: 200, days: 30 } })
).data,
// M5: mode 미확정(undefined) 동안은 v2/legacy 어느 쪽으로도 조회하지 않음
enabled: tenantMode !== undefined,
refetchInterval: 10000,
})