From b4a1fc79abbf490a2178d3c51593987624fa48a6 Mon Sep 17 00:00:00 2001 From: shuim Date: Mon, 21 Sep 2026 10:26:51 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A9=80=ED=8B=B0=ED=85=8C=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=201=EB=8B=A8=EA=B3=84=20=E2=80=94=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=EB=B3=84=20KIS=20=EA=B3=84=EC=A2=8C=C2=B7?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=ED=8F=B4=EB=A6=AC=EC=98=A4=C2=B7=EC=88=98?= =?UTF-8?q?=EB=8F=99=EB=A7=A4=EB=A7=A4=20(MULTI=5FTENANT=5FENABLED=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20OFF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설계: 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) --- .env.example | 7 + CLAUDE.md | 3 +- Jenkinsfile | 33 +- .../versions/0009_user_kis_credentials.py | 65 ++ .../versions/0010_add_user_id_nullable.py | 79 ++ .../versions/0011_backfill_legacy_owner.py | 73 ++ backend/app/api/__init__.py | 24 +- backend/app/api/endpoints/auth.py | 110 +- backend/app/api/endpoints/kis_credentials.py | 314 ++++++ backend/app/api/endpoints/portfolio.py | 10 +- backend/app/api/endpoints/portfolio_v2.py | 509 +++++++++ backend/app/api/endpoints/trades_v2.py | 125 +++ backend/app/core/config.py | 7 + backend/app/core/crypto.py | 95 ++ backend/app/core/dt_utils.py | 27 + backend/app/core/tenant_auth.py | 134 +++ backend/app/main.py | 28 + backend/app/models/models.py | 67 +- backend/app/services/db_worker.py | 137 ++- backend/app/services/korea_investment_api.py | 55 + backend/app/services/loss_tracker.py | 82 +- backend/app/services/mdd_manager.py | 209 +++- backend/app/services/tenant/__init__.py | 5 + backend/app/services/tenant/bootstrap.py | 174 ++++ backend/app/services/tenant/context.py | 62 ++ backend/app/services/tenant/kis_pool.py | 75 ++ backend/app/services/tenant/registry.py | 251 +++++ backend/app/services/tenant/risk_factory.py | 71 ++ backend/app/services/trade_executor.py | 105 ++ backend/pytest.ini | 3 + backend/requirements.txt | 1 + backend/tests/integration/__init__.py | 0 backend/tests/integration/conftest.py | 91 ++ .../tests/integration/test_kis_credentials.py | 202 ++++ .../tests/integration/test_multi_tenant.py | 964 ++++++++++++++++++ docs/multi_tenant_design.md | 269 +++++ docs/multi_tenant_phase1_review.md | 148 +++ frontend/src/App.tsx | 2 + .../src/components/AccountSetupNotice.tsx | 59 ++ frontend/src/components/JournalPanel.tsx | 7 +- frontend/src/components/Layout.tsx | 3 +- frontend/src/components/PortfolioPanel.tsx | 26 +- frontend/src/components/PortfolioSummary.tsx | 11 +- .../src/components/RecommendationPanel.tsx | 13 +- frontend/src/hooks/useAccountSetupStatus.ts | 63 ++ frontend/src/hooks/usePortfolioData.ts | 70 +- frontend/src/hooks/useTradeActions.ts | 96 +- frontend/src/lib/api.ts | 72 ++ frontend/src/lib/tenant.ts | 52 + frontend/src/pages/AccountSettings.tsx | 525 ++++++++++ frontend/src/pages/Dashboard.tsx | 82 +- frontend/src/pages/TradeHistory.tsx | 15 +- 52 files changed, 5614 insertions(+), 96 deletions(-) create mode 100644 backend/alembic/versions/0009_user_kis_credentials.py create mode 100644 backend/alembic/versions/0010_add_user_id_nullable.py create mode 100644 backend/alembic/versions/0011_backfill_legacy_owner.py create mode 100644 backend/app/api/endpoints/kis_credentials.py create mode 100644 backend/app/api/endpoints/portfolio_v2.py create mode 100644 backend/app/api/endpoints/trades_v2.py create mode 100644 backend/app/core/crypto.py create mode 100644 backend/app/core/dt_utils.py create mode 100644 backend/app/core/tenant_auth.py create mode 100644 backend/app/services/tenant/__init__.py create mode 100644 backend/app/services/tenant/bootstrap.py create mode 100644 backend/app/services/tenant/context.py create mode 100644 backend/app/services/tenant/kis_pool.py create mode 100644 backend/app/services/tenant/registry.py create mode 100644 backend/app/services/tenant/risk_factory.py create mode 100644 backend/pytest.ini create mode 100644 backend/tests/integration/__init__.py create mode 100644 backend/tests/integration/conftest.py create mode 100644 backend/tests/integration/test_kis_credentials.py create mode 100644 backend/tests/integration/test_multi_tenant.py create mode 100644 docs/multi_tenant_design.md create mode 100644 docs/multi_tenant_phase1_review.md create mode 100644 frontend/src/components/AccountSetupNotice.tsx create mode 100644 frontend/src/hooks/useAccountSetupStatus.ts create mode 100644 frontend/src/lib/tenant.ts create mode 100644 frontend/src/pages/AccountSettings.tsx diff --git a/.env.example b/.env.example index 145c93f..15ddc00 100644 --- a/.env.example +++ b/.env.example @@ -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= # 비우면 부트스트랩 관리자 시드를 건너뜀 + # ═══════════════════════════════════════════════════════════════════════════════ # 데이터베이스 및 캐시 # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/CLAUDE.md b/CLAUDE.md index f4c69e1..73b4c71 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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()` 사용. diff --git a/Jenkinsfile b/Jenkinsfile index 3995d30..9057b83 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -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 { diff --git a/backend/alembic/versions/0009_user_kis_credentials.py b/backend/alembic/versions/0009_user_kis_credentials.py new file mode 100644 index 0000000..38b304a --- /dev/null +++ b/backend/alembic/versions/0009_user_kis_credentials.py @@ -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") diff --git a/backend/alembic/versions/0010_add_user_id_nullable.py b/backend/alembic/versions/0010_add_user_id_nullable.py new file mode 100644 index 0000000..a0bef02 --- /dev/null +++ b/backend/alembic/versions/0010_add_user_id_nullable.py @@ -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") diff --git a/backend/alembic/versions/0011_backfill_legacy_owner.py b/backend/alembic/versions/0011_backfill_legacy_owner.py new file mode 100644 index 0000000..5e88e6a --- /dev/null +++ b/backend/alembic/versions/0011_backfill_legacy_owner.py @@ -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 = 실행. + pass diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 1401bd5..f1aa616 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -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"], diff --git a/backend/app/api/endpoints/auth.py b/backend/app/api/endpoints/auth.py index 38605ed..65706ca 100644 --- a/backend/app/api/endpoints/auth.py +++ b/backend/app/api/endpoints/auth.py @@ -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, + ) diff --git a/backend/app/api/endpoints/kis_credentials.py b/backend/app/api/endpoints/kis_credentials.py new file mode 100644 index 0000000..b99a032 --- /dev/null +++ b/backend/app/api/endpoints/kis_credentials.py @@ -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) diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py index 09c0d4e..ca469c2 100644 --- a/backend/app/api/endpoints/portfolio.py +++ b/backend/app/api/endpoints/portfolio.py @@ -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" diff --git a/backend/app/api/endpoints/portfolio_v2.py b/backend/app/api/endpoints/portfolio_v2.py new file mode 100644 index 0000000..cb4b560 --- /dev/null +++ b/backend/app/api/endpoints/portfolio_v2.py @@ -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), + } diff --git a/backend/app/api/endpoints/trades_v2.py b/backend/app/api/endpoints/trades_v2.py new file mode 100644 index 0000000..e89fec2 --- /dev/null +++ b/backend/app/api/endpoints/trades_v2.py @@ -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"} diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 9717df7..3a5d8a6 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 = "" # 비어있으면 부트스트랩 관리자 시드 건너뜀 + # ═════════════════════════════════════════════════════════════════════════════ # 데이터베이스 & 메시지 큐 # ═════════════════════════════════════════════════════════════════════════════ diff --git a/backend/app/core/crypto.py b/backend/app/core/crypto.py new file mode 100644 index 0000000..3d8ff94 --- /dev/null +++ b/backend/app/core/crypto.py @@ -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 diff --git a/backend/app/core/dt_utils.py b/backend/app/core/dt_utils.py new file mode 100644 index 0000000..88fd78f --- /dev/null +++ b/backend/app/core/dt_utils.py @@ -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) diff --git a/backend/app/core/tenant_auth.py b/backend/app/core/tenant_auth.py new file mode 100644 index 0000000..1feae2e --- /dev/null +++ b/backend/app/core/tenant_auth.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 958ecb5..43e5670 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 7dd8076..d47df66 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -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) diff --git a/backend/app/services/db_worker.py b/backend/app/services/db_worker.py index f009aef..925dc87 100644 --- a/backend/app/services/db_worker.py +++ b/backend/app/services/db_worker.py @@ -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", diff --git a/backend/app/services/korea_investment_api.py b/backend/app/services/korea_investment_api.py index 729cbf0..752bdf7 100644 --- a/backend/app/services/korea_investment_api.py +++ b/backend/app/services/korea_investment_api.py @@ -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 diff --git a/backend/app/services/loss_tracker.py b/backend/app/services/loss_tracker.py index f9f0898..b35ed26 100644 --- a/backend/app/services/loss_tracker.py +++ b/backend/app/services/loss_tracker.py @@ -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: """해당 종목이 진입 금지 상태인지 확인""" diff --git a/backend/app/services/mdd_manager.py b/backend/app/services/mdd_manager.py index 8ca0e70..e2fed8d 100644 --- a/backend/app/services/mdd_manager.py +++ b/backend/app/services/mdd_manager.py @@ -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() diff --git a/backend/app/services/tenant/__init__.py b/backend/app/services/tenant/__init__.py new file mode 100644 index 0000000..ad501a8 --- /dev/null +++ b/backend/app/services/tenant/__init__.py @@ -0,0 +1,5 @@ +"""멀티테넌트(사용자별 매매) 서비스 계층 — 1단계. + +- bootstrap: 앱 최초 기동 시 관리자 계정·KIS 자격증명 이관 시드 +- (2단계 예정) context.py / registry.py / kis_pool.py +""" diff --git a/backend/app/services/tenant/bootstrap.py b/backend/app/services/tenant/bootstrap.py new file mode 100644 index 0000000..f0577bd --- /dev/null +++ b/backend/app/services/tenant/bootstrap.py @@ -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}") diff --git a/backend/app/services/tenant/context.py b/backend/app/services/tenant/context.py new file mode 100644 index 0000000..60f66fc --- /dev/null +++ b/backend/app/services/tenant/context.py @@ -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 diff --git a/backend/app/services/tenant/kis_pool.py b/backend/app/services/tenant/kis_pool.py new file mode 100644 index 0000000..876dd23 --- /dev/null +++ b/backend/app/services/tenant/kis_pool.py @@ -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() diff --git a/backend/app/services/tenant/registry.py b/backend/app/services/tenant/registry.py new file mode 100644 index 0000000..d0febb6 --- /dev/null +++ b/backend/app/services/tenant/registry.py @@ -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) diff --git a/backend/app/services/tenant/risk_factory.py b/backend/app/services/tenant/risk_factory.py new file mode 100644 index 0000000..503f769 --- /dev/null +++ b/backend/app/services/tenant/risk_factory.py @@ -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}") diff --git a/backend/app/services/trade_executor.py b/backend/app/services/trade_executor.py index 04974de..1a5fd65 100644 --- a/backend/app/services/trade_executor.py +++ b/backend/app/services/trade_executor.py @@ -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 상태면 자동 취소. diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..14c50e7 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +markers = + integration: 컨테이너 Postgres(+fakeredis/mock KIS)가 필요한 통합 테스트 — 라이브 DB/Redis/KIS에 접근하지 않음 diff --git a/backend/requirements.txt b/backend/requirements.txt index 8f3a537..2a6b6b2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/integration/__init__.py b/backend/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py new file mode 100644 index 0000000..c4fdc88 --- /dev/null +++ b/backend/tests/integration/conftest.py @@ -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} diff --git a/backend/tests/integration/test_kis_credentials.py b/backend/tests/integration/test_kis_credentials.py new file mode 100644 index 0000000..b6766ad --- /dev/null +++ b/backend/tests/integration/test_kis_credentials.py @@ -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 diff --git a/backend/tests/integration/test_multi_tenant.py b/backend/tests/integration/test_multi_tenant.py new file mode 100644 index 0000000..5bb7975 --- /dev/null +++ b/backend/tests/integration/test_multi_tenant.py @@ -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 대상이 아니다" diff --git a/docs/multi_tenant_design.md b/docs/multi_tenant_design.md new file mode 100644 index 0000000..720e89a --- /dev/null +++ b/docs/multi_tenant_design.md @@ -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`에 `} />`, `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`가 사용자 보유 기준으로 바뀌는 것은 로직 변경이 아니라 입력 스코프 변경이므로 동일하게 면제. 사이징 기준 자본이 "계좌 총자산"인 점은 기존과 동일. diff --git a/docs/multi_tenant_phase1_review.md b/docs/multi_tenant_phase1_review.md new file mode 100644 index 0000000..ca650f8 --- /dev/null +++ b/docs/multi_tenant_phase1_review.md @@ -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 라우터 격리) 해소 후. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9421ddc..84c13f9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> diff --git a/frontend/src/components/AccountSetupNotice.tsx b/frontend/src/components/AccountSetupNotice.tsx new file mode 100644 index 0000000..4221cfc --- /dev/null +++ b/frontend/src/components/AccountSetupNotice.tsx @@ -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 ( +
+ + {message || DEFAULT_MESSAGE} + + 계좌 설정 + +
+ ) + } + + return ( +
+ + {message || DEFAULT_MESSAGE} + + 내 계좌 설정으로 이동 + +
+ ) +} + +export default AccountSetupNotice diff --git a/frontend/src/components/JournalPanel.tsx b/frontend/src/components/JournalPanel.tsx index 6f68809..75cd142 100644 --- a/frontend/src/components/JournalPanel.tsx +++ b/frontend/src/components/JournalPanel.tsx @@ -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 @@ -158,6 +166,12 @@ interface PortfolioPanelProps { onRefetchAiAnalysis: () => void /** 전량 청산 콜백 */ onSell: (stockCode: string, stockName: string, quantity: number) => Promise + /** 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 */}
- {total > 0 ? ( + {accountSetupRequired ? ( +
+ +
+ ) : total > 0 ? ( <> {renderGroup(dayStocks, '데이트레이딩', , 'text-[#f97316]')} {renderGroup(swingStocks, '스윙', , 'text-[#a78bfa]')} diff --git a/frontend/src/components/PortfolioSummary.tsx b/frontend/src/components/PortfolioSummary.tsx index 18ce83e..412cec4 100644 --- a/frontend/src/components/PortfolioSummary.tsx +++ b/frontend/src/components/PortfolioSummary.tsx @@ -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 ; + } + if (error) { return (
diff --git a/frontend/src/components/RecommendationPanel.tsx b/frontend/src/components/RecommendationPanel.tsx index b2ff40c..de538b9 100644 --- a/frontend/src/components/RecommendationPanel.tsx +++ b/frontend/src/components/RecommendationPanel.tsx @@ -32,6 +32,8 @@ interface RecommendationItemProps { isPending: boolean livePrices: Record | undefined onBuy: (stockCode: string, stockName: string) => Promise + /** 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) => { 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(({
+
+ +
+ 마지막 검증:{' '} + {credentials!.last_verified_at + ? new Date(credentials!.last_verified_at).toLocaleString('ko-KR') + : '없음'} +
+ + {/* 연결 테스트 */} +
+ + + {/* 자동매매 토글 */} +
+ + 자동매매 + {!credentials!.is_enabled && ( + (연결 테스트 필요) + )} +
+
+ + {verifyBanner && ( +
+ {verifyBanner.type === 'success' ? ( + + ) : ( + + )} +
+

{verifyBanner.msg}

+ {verifyBanner.balance && ( +
+                                                {JSON.stringify(verifyBanner.balance, null, 2)}
+                                            
+ )} +
+
+ )} +
+ )} +
+ + {/* 등록/교체 폼 */} +
+

+ {isRegistered ? '계좌 교체' : '계좌 등록'} +

+ + {/* 모드 라디오 */} +
+ 투자 모드 +
+ + +
+
+ +
+
+ +
+ 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" + /> + +
+
+ +
+ +
+ 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" + /> + +
+
+
+ +
+ + 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" + /> +
+ + {formError && ( +
+ {formError} +
+ )} + + +
+ + + {/* 실전 계좌 확인 모달 */} + {showRealConfirm && ( +
+
+
+ +

실전 계좌 등록 확인

+
+

+ 실전 계좌로 등록하면 자동매매 활성화 시 실제 주문이 + 체결됩니다. 계속하시겠습니까? +

+
+ + +
+
+
+ )} + + {/* 삭제 확인 모달 */} + {showDeleteConfirm && ( +
+
+
+ +

계좌 삭제 확인

+
+

+ 등록된 KIS 계좌 자격증명을 삭제합니다. 자동매매가 즉시 비활성화됩니다. +

+
+ + +
+
+
+ )} + + ) +} + +export default AccountSettings diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 3a19f1a..f26a30c 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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(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() 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 = () => { ))} - + {tradeAccountNotice && ( + + )} +
@@ -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} />
@@ -770,12 +834,14 @@ const Dashboard = () => { heldStockCodes={heldStockCodes} pendingBuyStockCodes={pendingBuyStockCodes} livePrices={livePrices} - onBuy={handleBuy} + onBuy={handleBuyGuarded} + buyDisabled={!tradeActionsReady} /> diff --git a/frontend/src/pages/TradeHistory.tsx b/frontend/src/pages/TradeHistory.tsx index 9af934f..db9c86d 100644 --- a/frontend/src/pages/TradeHistory.tsx +++ b/frontend/src/pages/TradeHistory.tsx @@ -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(today) const [dateTo, setDateTo] = useState(today) + const { mode: tenantMode } = useTenantMode() const { data: trades = [], isLoading, isError } = useQuery({ - 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, })