Merge branch 'dev' into main — 멀티테넌트 1단계 (MT 기본 OFF, 관리자 부트스트랩)
This commit is contained in:
@@ -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= # 비우면 부트스트랩 관리자 시드를 건너뜀
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 데이터베이스 및 캐시
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
- **인프라·ML**: Docker Compose, Nginx, Alembic / PyTorch, LightGBM, scikit-learn
|
||||
- **연동**: 한국투자증권(KIS) REST·WebSocket, MCP 서버(stdio) / AI 계층: `AI_PROVIDER`=claude|gemini
|
||||
- **주요 .env**: `KIS_MODE`(virtual|real), `TOKEN_SAVE_MODE`(JSON 압축+차트 30일 제한), `AUTO_TRADE_ENABLED`, `US_TRADING_ENABLED`
|
||||
- **배포**: 코드 수정 후 `docker compose up -d --build`(옛 이미지 재사용 방지) — **스키마(모델) 변경 시 반드시 `alembic upgrade head`를 이미지 기동보다 먼저 실행**(`create_all`은 기존 테이블에 컬럼을 추가하지 않음, H1).
|
||||
|
||||
## 2. 구조 (`backend/app/`)
|
||||
- `api/endpoints/` — 20개 모듈 (portfolio, market_data, trades, day_trading, backtest 등)
|
||||
@@ -22,7 +23,7 @@
|
||||
|
||||
## 3. 아키텍처 원칙 (필수)
|
||||
1. **실시간 계층 DB I/O 절대 금지** — API 콜백·웹소켓·실시간 스케줄러에서 `AsyncSessionLocal()`/`commit()` 호출 금지.
|
||||
2. **모든 DB 쓰기는 `db_worker.py`의 `db_task_queue` 경유** (실패 시 지수 백오프 1→2→4초, 최대 3회 재시도). 예외: `sync_portfolio()`만 단일 트랜잭션 필요로 엔드포인트 직접 DB 접근 허용.
|
||||
2. **모든 DB 쓰기는 `db_worker.py`의 `db_task_queue` 경유** (실패 시 지수 백오프 1→2→4초, 최대 3회 재시도). 예외: `sync_portfolio()`만 단일 트랜잭션 필요로 엔드포인트 직접 DB 접근 허용. auth/credentials/bootstrap 시드(회원가입·로그인, `user_kis_credentials` 등록/삭제, 앱 기동 시 관리자·자격증명 시드)도 단일 트랜잭션 직접 접근 허용 — 실시간 계층(콜백·스케줄러) 아님.
|
||||
3. 지원 task: insert_trade_history, update_trade_history(+_by_order_id), upsert_stock_analysis, bulk_upsert_stock_analysis, upsert_stock_ban, upsert_portfolio, upsert_active_trade, close_active_trade, insert_trade_journal, log_mcp_deny
|
||||
4. **동시성**: trade_executor `_order_semaphores`(종목별 동시 주문 방지) / Redis 분산 락(워커 간 동시 매도 방지) / loss_tracker·mdd_manager `asyncio.Lock`. 동시 접근 경로에서는 `is_banned_safe()`, `record_trade_result_safe()` 사용.
|
||||
|
||||
|
||||
33
Jenkinsfile
vendored
33
Jenkinsfile
vendored
@@ -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 {
|
||||
|
||||
65
backend/alembic/versions/0009_user_kis_credentials.py
Normal file
65
backend/alembic/versions/0009_user_kis_credentials.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""add user_kis_credentials table (multi-tenant phase 1)
|
||||
|
||||
Revision ID: 0009
|
||||
Revises: 0008
|
||||
Create Date: 2026-09-21
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0009"
|
||||
down_revision = "0008"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
exists = conn.execute(sa.text(
|
||||
"SELECT 1 FROM information_schema.tables WHERE table_name='user_kis_credentials'"
|
||||
)).fetchone()
|
||||
if exists:
|
||||
return # create_all 로 이미 생성됨
|
||||
op.create_table(
|
||||
"user_kis_credentials",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("mode", sa.String(10), nullable=False),
|
||||
sa.Column("app_key_enc", sa.Text(), nullable=False),
|
||||
sa.Column("app_secret_enc", sa.Text(), nullable=False),
|
||||
sa.Column("account_no_enc", sa.Text(), nullable=False),
|
||||
sa.Column("account_product_code", sa.String(2), nullable=False, server_default="01"),
|
||||
sa.Column("app_key_hash", sa.String(64), nullable=False),
|
||||
sa.Column("app_key_hint", sa.String(8), nullable=True),
|
||||
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("auto_trade_enabled", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("last_verified_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("key_version", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_user_kis_credentials_user_id", "user_kis_credentials", ["user_id"], unique=True
|
||||
)
|
||||
op.create_index(
|
||||
"ix_user_kis_credentials_app_key_hash", "user_kis_credentials", ["app_key_hash"], unique=True
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# L3: 테이블이 `create_all`(SQLAlchemy 모델의 `unique=True` 컬럼)로 먼저 생성된
|
||||
# 경우 실제로는 named index가 아니라 unique constraint(`..._key`)로 존재해
|
||||
# `drop_index`가 실패한다. 존재할 때만 드롭한다.
|
||||
conn = op.get_bind()
|
||||
for index_name in (
|
||||
"ix_user_kis_credentials_app_key_hash",
|
||||
"ix_user_kis_credentials_user_id",
|
||||
):
|
||||
exists = conn.execute(sa.text(
|
||||
"SELECT 1 FROM pg_indexes WHERE tablename='user_kis_credentials' AND indexname=:name"
|
||||
), {"name": index_name}).fetchone()
|
||||
if exists:
|
||||
op.drop_index(index_name, table_name="user_kis_credentials")
|
||||
op.drop_table("user_kis_credentials")
|
||||
79
backend/alembic/versions/0010_add_user_id_nullable.py
Normal file
79
backend/alembic/versions/0010_add_user_id_nullable.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""add nullable user_id to tenant-scoped tables + stock_bans_v2 (multi-tenant phase 1)
|
||||
|
||||
Revision ID: 0010
|
||||
Revises: 0009
|
||||
Create Date: 2026-09-21
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0010"
|
||||
down_revision = "0009"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# (table, index_name)
|
||||
_USER_ID_TABLES = [
|
||||
"portfolio",
|
||||
"trade_history",
|
||||
"active_trades",
|
||||
"trade_journals",
|
||||
"trading_sessions",
|
||||
"mcp_tool_calls",
|
||||
"claude_analysis_log",
|
||||
]
|
||||
|
||||
|
||||
def _has_column(conn, table: str, column: str) -> bool:
|
||||
return conn.execute(sa.text(
|
||||
"SELECT 1 FROM information_schema.columns WHERE table_name=:t AND column_name=:c"
|
||||
), {"t": table, "c": column}).fetchone() is not None
|
||||
|
||||
|
||||
def _has_table(conn, table: str) -> bool:
|
||||
return conn.execute(sa.text(
|
||||
"SELECT 1 FROM information_schema.tables WHERE table_name=:t"
|
||||
), {"t": table}).fetchone() is not None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
for table in _USER_ID_TABLES:
|
||||
if not _has_table(conn, table):
|
||||
continue # create_all 이전 스키마 없음 — 스킵(다음 실행에서 재시도)
|
||||
if _has_column(conn, table, "user_id"):
|
||||
continue # create_all 로 이미 생성됨
|
||||
op.add_column(
|
||||
table,
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
)
|
||||
op.create_index(f"ix_{table}_user_id", table, ["user_id"])
|
||||
|
||||
if not _has_table(conn, "stock_bans_v2"):
|
||||
op.create_table(
|
||||
"stock_bans_v2",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("stock_code", sa.String(20), nullable=False),
|
||||
sa.Column("consecutive_losses", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("ban_until", sa.DateTime(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_stock_bans_v2_user_id", "stock_bans_v2", ["user_id"])
|
||||
op.create_index("ix_stock_bans_v2_stock_code", "stock_bans_v2", ["stock_code"])
|
||||
op.create_unique_constraint(
|
||||
"uq_stock_bans_v2_user_stock", "stock_bans_v2", ["user_id", "stock_code"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if _has_table(conn, "stock_bans_v2"):
|
||||
op.drop_table("stock_bans_v2")
|
||||
for table in _USER_ID_TABLES:
|
||||
if _has_table(conn, table) and _has_column(conn, table, "user_id"):
|
||||
op.drop_index(f"ix_{table}_user_id", table_name=table)
|
||||
op.drop_column(table, "user_id")
|
||||
73
backend/alembic/versions/0011_backfill_legacy_owner.py
Normal file
73
backend/alembic/versions/0011_backfill_legacy_owner.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""backfill legacy owner user_id + stock_bans -> stock_bans_v2 (multi-tenant phase 1)
|
||||
|
||||
Revision ID: 0011
|
||||
Revises: 0010
|
||||
Create Date: 2026-09-21
|
||||
|
||||
- legacy_owner = users 중 is_admin=True, id 최소인 사용자.
|
||||
- 없으면 스킵 (앱 기동 시 ensure_bootstrap_admin() 또는 관리자 API
|
||||
POST /auth/admin/tenant/migrate-legacy 가 동일 로직으로 재실행 가능 — idempotent).
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0011"
|
||||
down_revision = "0010"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_BACKFILL_TABLES = [
|
||||
"portfolio",
|
||||
"trade_history",
|
||||
"active_trades",
|
||||
"trade_journals",
|
||||
"trading_sessions",
|
||||
"mcp_tool_calls",
|
||||
"claude_analysis_log",
|
||||
]
|
||||
|
||||
|
||||
def _has_table(conn, table: str) -> bool:
|
||||
return conn.execute(sa.text(
|
||||
"SELECT 1 FROM information_schema.tables WHERE table_name=:t"
|
||||
), {"t": table}).fetchone() is not None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
if not _has_table(conn, "users"):
|
||||
return
|
||||
|
||||
owner_row = conn.execute(sa.text(
|
||||
"SELECT id FROM users WHERE is_admin = TRUE ORDER BY id LIMIT 1"
|
||||
)).fetchone()
|
||||
if owner_row is None:
|
||||
return # 관리자 미존재 — migrate-legacy API 또는 ensure_bootstrap_admin() 이 지연 백필
|
||||
|
||||
owner_id = owner_row[0]
|
||||
|
||||
for table in _BACKFILL_TABLES:
|
||||
if not _has_table(conn, table):
|
||||
continue
|
||||
conn.execute(sa.text(
|
||||
f"UPDATE {table} SET user_id = :owner WHERE user_id IS NULL"
|
||||
), {"owner": owner_id})
|
||||
|
||||
if _has_table(conn, "stock_bans") and _has_table(conn, "stock_bans_v2"):
|
||||
conn.execute(sa.text(
|
||||
"""
|
||||
INSERT INTO stock_bans_v2 (user_id, stock_code, consecutive_losses, ban_until, updated_at)
|
||||
SELECT :owner, stock_code, consecutive_losses, ban_until, updated_at
|
||||
FROM stock_bans
|
||||
ON CONFLICT (user_id, stock_code) DO NOTHING
|
||||
"""
|
||||
), {"owner": owner_id})
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 데이터 백필 롤백은 위험(다른 정상 데이터와 구분 불가) — no-op.
|
||||
# 필요 시 수동으로 UPDATE ... SET user_id = NULL WHERE user_id = <owner_id> 실행.
|
||||
pass
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
314
backend/app/api/endpoints/kis_credentials.py
Normal file
314
backend/app/api/endpoints/kis_credentials.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""사용자별 KIS(한국투자증권) 자격증명 관리 API (멀티테넌트 1단계, 설계문서 §5.3).
|
||||
|
||||
- 저장/삭제는 "조회 → upsert/삭제 → 응답"이 한 요청 안에서 원자적으로 끝나야 하므로
|
||||
auth.py 의 register/login 과 같은 성격으로 엔드포인트에서 직접 세션을 커밋한다
|
||||
(CLAUDE.md §3.2 예외 — 실시간 계층이 아님).
|
||||
- 평문 app_key/app_secret/account_no 는 어떤 응답·로그에도 노출하지 않는다
|
||||
(app_key_hint·마스킹된 계좌번호만 노출).
|
||||
- API 키(X-API-Key=SECRET_KEY) 호출자는 `get_current_user` 가 legacy owner 로
|
||||
매핑하므로(§5.4), legacy owner 도 없는 최초 상태(`user.id is None`)에서만 400을
|
||||
반환하고 그 외에는 정상적으로 legacy owner 본인의 자격증명을 다룬다.
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.crypto import CredentialDecryptError, current_key_version, decrypt, encrypt, is_encryption_configured
|
||||
from app.core.database import get_db
|
||||
from app.core.tenant_auth import CurrentUser, get_current_user
|
||||
from app.models.models import UserKisCredential
|
||||
from app.services.korea_investment_api import KoreaInvestmentAPI
|
||||
from app.services.tenant.registry import user_context_registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
_VERIFY_COOLDOWN_SEC = 60
|
||||
# user_id -> 마지막 verify 시도 monotonic 시각 (인메모리, 프로세스 재시작 시 리셋되어도 무방)
|
||||
_last_verify_attempt: dict[int, float] = {}
|
||||
|
||||
|
||||
# ── 스키마 ────────────────────────────────────────────────────────────────
|
||||
class CredentialsIn(BaseModel):
|
||||
mode: str
|
||||
app_key: str = Field(..., min_length=1)
|
||||
app_secret: str = Field(..., min_length=1)
|
||||
account_no: str = Field(..., min_length=1)
|
||||
|
||||
@field_validator("mode")
|
||||
@classmethod
|
||||
def _mode_valid(cls, v: str) -> str:
|
||||
if v not in ("virtual", "real"):
|
||||
raise ValueError("mode는 virtual 또는 real 이어야 합니다.")
|
||||
return v
|
||||
|
||||
@field_validator("account_no")
|
||||
@classmethod
|
||||
def _account_no_valid(cls, v: str) -> str:
|
||||
digits = v.replace("-", "")
|
||||
if not digits.isdigit() or len(digits) != 10:
|
||||
raise ValueError("계좌번호는 하이픈 제거 후 숫자 10자리(계좌 8자리+상품코드 2자리)여야 합니다.")
|
||||
return digits
|
||||
|
||||
|
||||
class CredentialsOut(BaseModel):
|
||||
mode: str
|
||||
app_key_hint: str
|
||||
account_masked: str
|
||||
is_enabled: bool
|
||||
auto_trade_enabled: bool
|
||||
last_verified_at: datetime | None
|
||||
updated_at: datetime | None
|
||||
decrypt_error: bool = False
|
||||
|
||||
|
||||
class AutoTradeIn(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class VerifyOut(BaseModel):
|
||||
ok: bool
|
||||
balance_summary: dict
|
||||
verified_at: datetime
|
||||
|
||||
|
||||
# ── 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
def _require_user_id(user: CurrentUser) -> int:
|
||||
if user.id is None:
|
||||
raise HTTPException(status_code=400, detail="관리자 계정을 먼저 생성하세요.")
|
||||
return user.id
|
||||
|
||||
|
||||
async def _get_own(db: AsyncSession, user_id: int) -> UserKisCredential | None:
|
||||
result = await db.execute(select(UserKisCredential).where(UserKisCredential.user_id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
def _mask_account(account_no_plain: str) -> str:
|
||||
"""계좌 8자리 기준 마스킹: '******' + 뒤 2자리 (상품코드 제외)."""
|
||||
acct = account_no_plain[:8]
|
||||
return "******" + acct[-2:]
|
||||
|
||||
|
||||
async def _to_out(cred: UserKisCredential) -> CredentialsOut:
|
||||
"""계좌번호 마스킹 표시를 위해 복호화(M4: 스레드로 위임)한다.
|
||||
|
||||
복호화 실패(L2, 키 로테이션 실패 등)는 500 대신 `decrypt_error=true`로 응답해
|
||||
마스킹 계좌 대신 원인 진단이 가능함을 알린다.
|
||||
"""
|
||||
try:
|
||||
account_masked = _mask_account(
|
||||
await asyncio.to_thread(decrypt, cred.account_no_enc, cred.key_version)
|
||||
)
|
||||
decrypt_error = False
|
||||
except CredentialDecryptError as e:
|
||||
logger.error("[KISCredentials] 계좌번호 복호화 실패 user_id=%s: %s", cred.user_id, e)
|
||||
account_masked = "******"
|
||||
decrypt_error = True
|
||||
|
||||
return CredentialsOut(
|
||||
mode=cred.mode,
|
||||
app_key_hint=(cred.app_key_hint or "") + "…",
|
||||
account_masked=account_masked,
|
||||
is_enabled=cred.is_enabled,
|
||||
auto_trade_enabled=cred.auto_trade_enabled,
|
||||
last_verified_at=cred.last_verified_at,
|
||||
updated_at=cred.updated_at,
|
||||
decrypt_error=decrypt_error,
|
||||
)
|
||||
|
||||
|
||||
def _pick_float(d: dict, keys: list[str]) -> float:
|
||||
for k in keys:
|
||||
v = d.get(k)
|
||||
if v not in (None, ""):
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return 0.0
|
||||
|
||||
|
||||
# ── 엔드포인트 ────────────────────────────────────────────────────────────
|
||||
@router.get("/credentials", response_model=CredentialsOut)
|
||||
async def get_credentials(
|
||||
user: CurrentUser = Depends(get_current_user), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
user_id = _require_user_id(user)
|
||||
cred = await _get_own(db, user_id)
|
||||
if cred is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="등록된 KIS 자격증명이 없습니다.")
|
||||
return await _to_out(cred)
|
||||
|
||||
|
||||
@router.put("/credentials", response_model=CredentialsOut)
|
||||
async def put_credentials(
|
||||
body: CredentialsIn,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user_id = _require_user_id(user)
|
||||
if not is_encryption_configured():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="CREDENTIALS_ENCRYPTION_KEY/SECRET_KEY 미설정 또는 기본값 — 자격증명 암호화 저장 불가",
|
||||
)
|
||||
|
||||
app_key_hash = hashlib.sha256(body.app_key.encode()).hexdigest()
|
||||
dup = await db.execute(
|
||||
select(UserKisCredential).where(
|
||||
UserKisCredential.app_key_hash == app_key_hash,
|
||||
UserKisCredential.user_id != user_id,
|
||||
)
|
||||
)
|
||||
if dup.scalar_one_or_none() is not None:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="이미 다른 사용자가 등록한 앱키입니다.")
|
||||
|
||||
now = datetime.utcnow()
|
||||
cred = await _get_own(db, user_id)
|
||||
is_new = cred is None
|
||||
if is_new:
|
||||
cred = UserKisCredential(user_id=user_id, auto_trade_enabled=False)
|
||||
db.add(cred)
|
||||
|
||||
cred.mode = body.mode
|
||||
cred.app_key_enc = encrypt(body.app_key)
|
||||
cred.app_secret_enc = encrypt(body.app_secret)
|
||||
cred.account_no_enc = encrypt(body.account_no)
|
||||
cred.account_product_code = body.account_no[8:10]
|
||||
cred.app_key_hash = app_key_hash
|
||||
cred.app_key_hint = body.app_key[:6]
|
||||
cred.is_enabled = False
|
||||
cred.last_verified_at = None
|
||||
cred.key_version = current_key_version()
|
||||
cred.updated_at = now
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="이미 다른 사용자가 등록한 앱키입니다.")
|
||||
await db.refresh(cred)
|
||||
|
||||
await user_context_registry.invalidate(user_id)
|
||||
logger.info(
|
||||
"[KISCredentials] 자격증명 %s user_id=%s mode=%s app_key=%s…",
|
||||
"생성" if is_new else "교체", user_id, body.mode, body.app_key[:6],
|
||||
)
|
||||
return await _to_out(cred)
|
||||
|
||||
|
||||
@router.delete("/credentials", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_credentials(
|
||||
user: CurrentUser = Depends(get_current_user), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
user_id = _require_user_id(user)
|
||||
cred = await _get_own(db, user_id)
|
||||
if cred is not None:
|
||||
await db.delete(cred)
|
||||
await db.commit()
|
||||
await user_context_registry.invalidate(user_id)
|
||||
logger.info("[KISCredentials] 자격증명 삭제 user_id=%s", user_id)
|
||||
|
||||
|
||||
@router.post("/credentials/verify", response_model=VerifyOut)
|
||||
async def verify_credentials(
|
||||
user: CurrentUser = Depends(get_current_user), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
user_id = _require_user_id(user)
|
||||
cred = await _get_own(db, user_id)
|
||||
if cred is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="등록된 KIS 자격증명이 없습니다.")
|
||||
|
||||
now_mono = time.monotonic()
|
||||
last = _last_verify_attempt.get(user_id)
|
||||
if last is not None:
|
||||
remaining = _VERIFY_COOLDOWN_SEC - (now_mono - last)
|
||||
if remaining > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"{int(remaining) + 1}초 후 다시 시도하세요.",
|
||||
)
|
||||
_last_verify_attempt[user_id] = now_mono
|
||||
|
||||
try:
|
||||
app_key = await asyncio.to_thread(decrypt, cred.app_key_enc, cred.key_version)
|
||||
app_secret = await asyncio.to_thread(decrypt, cred.app_secret_enc, cred.key_version)
|
||||
account_no = await asyncio.to_thread(decrypt, cred.account_no_enc, cred.key_version)
|
||||
except CredentialDecryptError as e:
|
||||
logger.error("[KISCredentials] verify 복호화 실패 user_id=%s: %s", user_id, e)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="자격증명 복호화 실패 — 서버 키 변경 여부 확인")
|
||||
|
||||
temp_kis = KoreaInvestmentAPI.for_user(
|
||||
user_id=user_id, app_key=app_key, app_secret=app_secret, account_no=account_no, mode=cred.mode,
|
||||
)
|
||||
try:
|
||||
balance = await temp_kis.get_balance()
|
||||
except Exception as e:
|
||||
# L5: KIS 예외 원문 대신 예외 타입명만 노출(방어적 — 현재 KIS 오류 본문에 키는 없음).
|
||||
logger.warning("[KISCredentials] verify 실패 user_id=%s: %s", user_id, e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"KIS 연결 확인 실패: {type(e).__name__} — 연결 실패",
|
||||
)
|
||||
finally:
|
||||
await temp_kis.close()
|
||||
|
||||
if balance.get("rt_cd") != "0":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=balance.get("msg1") or "KIS 잔고 조회 실패",
|
||||
)
|
||||
|
||||
output2 = balance.get("output2") or {}
|
||||
if isinstance(output2, list):
|
||||
output2 = output2[0] if output2 else {}
|
||||
|
||||
balance_summary = {
|
||||
"available_cash": _pick_float(output2, ["dnca_tot_amt", "d2_deposit", "nass_amt"]),
|
||||
"total_assets": _pick_float(output2, ["tot_evlu_amt", "tot_evlu_amt_smtl"]),
|
||||
"stock_value": _pick_float(output2, ["scts_evlu_amt", "evlu_amt_smtl_amt"]),
|
||||
}
|
||||
|
||||
now = datetime.utcnow()
|
||||
cred.last_verified_at = now
|
||||
cred.is_enabled = True
|
||||
cred.updated_at = now
|
||||
await db.commit()
|
||||
|
||||
logger.info("[KISCredentials] verify 성공 user_id=%s mode=%s", user_id, cred.mode)
|
||||
return VerifyOut(ok=True, balance_summary=balance_summary, verified_at=now)
|
||||
|
||||
|
||||
@router.patch("/auto-trade", response_model=CredentialsOut)
|
||||
async def patch_auto_trade(
|
||||
body: AutoTradeIn,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user_id = _require_user_id(user)
|
||||
cred = await _get_own(db, user_id)
|
||||
if cred is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="등록된 KIS 자격증명이 없습니다.")
|
||||
|
||||
if body.enabled and not cred.is_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="먼저 연결 테스트(verify)를 통과해야 자동매매를 활성화할 수 있습니다.",
|
||||
)
|
||||
|
||||
cred.auto_trade_enabled = body.enabled
|
||||
cred.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
# H3: 캐시된 UserContext.auto_trade_enabled 스냅샷이 갱신되도록 무효화(다음 호출에서 재조립).
|
||||
await user_context_registry.invalidate(user_id)
|
||||
logger.info("[KISCredentials] auto_trade_enabled=%s user_id=%s", body.enabled, user_id)
|
||||
return await _to_out(cred)
|
||||
@@ -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"
|
||||
|
||||
|
||||
509
backend/app/api/endpoints/portfolio_v2.py
Normal file
509
backend/app/api/endpoints/portfolio_v2.py
Normal file
@@ -0,0 +1,509 @@
|
||||
"""사용자 스코프 포트폴리오 API (`_v2`, 멀티테넌트 1단계 §5.7).
|
||||
|
||||
기존 `portfolio.py`/`manual_buy.py`의 함수·시그니처는 전혀 건드리지 않는다.
|
||||
이 파일은 `get_current_user`/`get_user_context`로 호출자를 해석해 사용자별
|
||||
KIS 계좌(`ctx.kis`)·리스크 상태(`ctx.mdd`, `ctx.loss_tracker`)로 조회·매매한다.
|
||||
|
||||
- `MULTI_TENANT_ENABLED=False`(기본)이면 `CurrentUser.effective_user_id`가
|
||||
legacy owner id 를 반환하고, `get_user_context`도 legacy 컨텍스트(기존 전역
|
||||
싱글톤)를 반환하므로 기존 `/portfolio`와 동일하게 동작한다.
|
||||
- Portfolio/TradeHistory/ActiveTrade 는 `user_id` 컬럼이 nullable로 이미 추가돼
|
||||
있으나 `portfolio.stock_code`는 아직 전역 unique(0012 이전)이므로, 서로 다른
|
||||
사용자가 동시에 같은 종목을 보유하면 DB unique 제약에 걸릴 수 있다(설계 문서
|
||||
§3.2, §7 열린 질문 — 0012 마이그레이션 전까지는 알려진 제약).
|
||||
- 레거시 호환 조회 규칙: `MULTI_TENANT_ENABLED=False`이면 `user_id == 대상 id`
|
||||
또는 `user_id IS NULL`(백필 전 데이터)을 모두 포함해서 조회한다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
import pytz
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.sql import false as sql_false
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.tenant_auth import CurrentUser, get_current_user
|
||||
from app.models.models import Portfolio
|
||||
from app.services.db_worker import (
|
||||
enqueue_close_active_trade,
|
||||
enqueue_close_active_trade_v2,
|
||||
enqueue_portfolio_upsert,
|
||||
enqueue_trade_history,
|
||||
)
|
||||
from app.services.tenant.context import UserContext
|
||||
from app.services.tenant.registry import get_user_context, user_context_registry
|
||||
from app.services.trade_executor import safe_place_order_v2
|
||||
from app.services.utils import _is_domestic
|
||||
|
||||
from app.api.endpoints.manual_buy import BuyRequest
|
||||
from app.api.endpoints.portfolio import PortfolioResponse, SellRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
_KST = pytz.timezone("Asia/Seoul")
|
||||
|
||||
|
||||
def _kst_now() -> datetime:
|
||||
return datetime.now(_KST).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _user_scope_filter(column, target_user_id: Optional[int]):
|
||||
"""레거시 호환 필터: MULTI_TENANT_ENABLED=False 이면 NULL(백필 전) 행도 포함.
|
||||
|
||||
M1: `target_user_id=None`(관리자 미존재 등)일 때 `column == None`은 SQLAlchemy가
|
||||
자동으로 `IS NULL`로 컴파일하므로, MULTI_TENANT_ENABLED=True 에서 그대로 두면
|
||||
"본인 없음"이 아니라 "NULL 행 전체 노출"이 되어버린다. True 모드에서는 fail-closed로
|
||||
빈 결과를 반환한다.
|
||||
"""
|
||||
if settings.MULTI_TENANT_ENABLED:
|
||||
if target_user_id is None:
|
||||
return sql_false()
|
||||
return column == target_user_id
|
||||
return or_(column == target_user_id, column.is_(None))
|
||||
|
||||
|
||||
async def _resolve_target_user_id(
|
||||
user: CurrentUser, user_id: Optional[int], db: AsyncSession
|
||||
) -> int:
|
||||
"""`?user_id=` 오버라이드(관리자 전용) 해석. 미지정 시 본인 effective_user_id."""
|
||||
if user_id is None:
|
||||
target = user.effective_user_id
|
||||
if target is None:
|
||||
raise HTTPException(status_code=400, detail="사용자 컨텍스트를 확인할 수 없습니다")
|
||||
return target
|
||||
if not user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="다른 사용자 조회는 관리자만 가능합니다")
|
||||
return user_id
|
||||
|
||||
|
||||
async def _resolve_ctx(
|
||||
user: CurrentUser, user_id: Optional[int], db: AsyncSession
|
||||
) -> UserContext:
|
||||
"""`?user_id=` 오버라이드를 반영한 `UserContext` 해석 (KIS 잔고 조회용)."""
|
||||
if user_id is None:
|
||||
return await user_context_registry.resolve(user, db)
|
||||
if not user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="다른 사용자 조회는 관리자만 가능합니다")
|
||||
legacy_ctx = await user_context_registry.legacy(db)
|
||||
if user_id == legacy_ctx.user_id:
|
||||
return legacy_ctx
|
||||
ctx = await user_context_registry.get(user_id, db)
|
||||
if ctx is None:
|
||||
raise HTTPException(status_code=404, detail=f"user_id={user_id} 의 KIS 계좌가 등록·검증되지 않았습니다")
|
||||
return ctx
|
||||
|
||||
|
||||
# ── GET /portfolio/v2 ─────────────────────────────────────────────────────
|
||||
@router.get("", response_model=list[PortfolioResponse])
|
||||
async def get_portfolio_v2(
|
||||
user_id: Optional[int] = None,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""사용자 스코프 보유 종목 조회. `?user_id=`는 관리자 전용 타 사용자 열람."""
|
||||
target_user_id = await _resolve_target_user_id(user, user_id, db)
|
||||
query = select(Portfolio).where(
|
||||
Portfolio.quantity > 0, _user_scope_filter(Portfolio.user_id, target_user_id)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
# ── GET /portfolio/v2/summary ─────────────────────────────────────────────
|
||||
@router.get("/summary")
|
||||
async def get_portfolio_summary_v2(
|
||||
user_id: Optional[int] = None,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""사용자 스코프 포트폴리오 요약. 총자산/예수금은 `ctx.kis.get_balance()` 기준."""
|
||||
target_user_id = await _resolve_target_user_id(user, user_id, db)
|
||||
ctx = await _resolve_ctx(user, user_id, db)
|
||||
|
||||
query = select(Portfolio).where(
|
||||
Portfolio.quantity > 0, _user_scope_filter(Portfolio.user_id, target_user_id)
|
||||
)
|
||||
holdings = list((await db.execute(query)).scalars().all())
|
||||
|
||||
total_value = sum(float(h.current_price or 0) * h.quantity for h in holdings)
|
||||
total_investment = sum(float(h.average_price or 0) * h.quantity for h in holdings)
|
||||
total_profit_loss = total_value - total_investment
|
||||
total_profit_loss_rate = (total_profit_loss / total_investment * 100) if total_investment else 0.0
|
||||
|
||||
available_cash = 0.0
|
||||
total_assets = 0.0
|
||||
try:
|
||||
balance = await ctx.kis.get_balance()
|
||||
output2 = balance.get("output2") or [{}]
|
||||
b = output2[0] if isinstance(output2, list) else output2
|
||||
available_cash = float(b.get("dnca_tot_amt") or 0)
|
||||
total_assets = float(b.get("tot_evlu_amt") or 0)
|
||||
except Exception as e:
|
||||
logger.warning(f"[portfolio_v2] user_id={target_user_id} 잔고 조회 실패: {e}")
|
||||
|
||||
return {
|
||||
"user_id": target_user_id,
|
||||
"total_stocks": len(holdings),
|
||||
"total_value": total_value,
|
||||
"total_investment": total_investment,
|
||||
"total_profit_loss": total_profit_loss,
|
||||
"total_profit_loss_rate": total_profit_loss_rate,
|
||||
"available_cash": available_cash,
|
||||
"total_assets": total_assets,
|
||||
}
|
||||
|
||||
|
||||
# ── POST /portfolio/v2/sync ───────────────────────────────────────────────
|
||||
_sync_locks: dict[int, asyncio.Lock] = {}
|
||||
|
||||
|
||||
def _get_sync_lock(user_id: int) -> asyncio.Lock:
|
||||
lock = _sync_locks.get(user_id)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
_sync_locks[user_id] = lock
|
||||
return lock
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
async def sync_portfolio_v2(
|
||||
ctx: UserContext = Depends(get_user_context),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""`ctx.kis.get_balance()` 로 KIS 잔고를 조회해 사용자 스코프 Portfolio를 upsert/삭제.
|
||||
|
||||
`sync_portfolio`(portfolio.py)와 동일하게 단일 트랜잭션 직접 DB 접근이 허용되는
|
||||
예외 경로다(CLAUDE.md §3.2). 다른 사용자 행은 절대 건드리지 않도록 모든 쿼리에
|
||||
`Portfolio.user_id == ctx.user_id` 조건을 명시한다. legacy 컨텍스트(멀티테넌트
|
||||
비활성 또는 API 키 호출)는 `user_id`가 legacy owner id 또는 None이며, 이 경우
|
||||
기존 `/portfolio/sync`가 이미 전체 동기화를 수행하므로 여기서는 스킵한다.
|
||||
"""
|
||||
if ctx.is_legacy:
|
||||
return {"message": "legacy 컨텍스트는 기존 /portfolio/sync 를 사용하세요", "skipped": True}
|
||||
if ctx.user_id is None:
|
||||
raise HTTPException(status_code=400, detail="사용자 컨텍스트를 확인할 수 없습니다")
|
||||
|
||||
lock = _get_sync_lock(ctx.user_id)
|
||||
try:
|
||||
await asyncio.wait_for(lock.acquire(), timeout=30)
|
||||
except asyncio.TimeoutError:
|
||||
raise HTTPException(status_code=503, detail="Portfolio sync timeout (30s)")
|
||||
|
||||
try:
|
||||
balance_data = await ctx.kis.get_balance()
|
||||
if not balance_data or balance_data.get("rt_cd") != "0":
|
||||
error_msg = balance_data.get("msg1", "Unknown error") if balance_data else "No response"
|
||||
raise HTTPException(status_code=502, detail=f"Balance API error: {error_msg}")
|
||||
|
||||
result = await db.execute(select(Portfolio).where(Portfolio.user_id == ctx.user_id))
|
||||
existing = {p.stock_code: p for p in result.scalars().all()}
|
||||
processed_codes: set[str] = set()
|
||||
|
||||
for item in balance_data.get("output1") or []:
|
||||
stock_code = item.get("pdno")
|
||||
quantity = int(item.get("hldg_qty", 0) or 0)
|
||||
if not stock_code or quantity <= 0:
|
||||
continue
|
||||
processed_codes.add(stock_code)
|
||||
average_price = Decimal(str(item.get("pchs_avg_pric", 0)))
|
||||
current_price = Decimal(str(item.get("prpr", 0)))
|
||||
profit_loss = Decimal(str(item.get("evlu_pfls_amt", 0)))
|
||||
profit_loss_rate = Decimal(str(item.get("evlu_pfls_rt", 0)))
|
||||
|
||||
if stock_code in existing:
|
||||
h = existing[stock_code]
|
||||
h.stock_name = item.get("prdt_name")
|
||||
h.quantity = quantity
|
||||
h.average_price = average_price
|
||||
h.current_price = current_price
|
||||
h.profit_loss = profit_loss
|
||||
h.profit_loss_rate = profit_loss_rate
|
||||
h.market = "KRX"
|
||||
else:
|
||||
db.add(Portfolio(
|
||||
user_id=ctx.user_id,
|
||||
stock_code=stock_code,
|
||||
stock_name=item.get("prdt_name"),
|
||||
quantity=quantity,
|
||||
average_price=average_price,
|
||||
current_price=current_price,
|
||||
profit_loss=profit_loss,
|
||||
profit_loss_rate=profit_loss_rate,
|
||||
market="KRX",
|
||||
))
|
||||
|
||||
for code, h in existing.items():
|
||||
if code not in processed_codes:
|
||||
await db.delete(h)
|
||||
|
||||
await db.commit()
|
||||
if ctx.portfolio_cache is not None:
|
||||
try:
|
||||
ctx.portfolio_cache.mark_changed()
|
||||
except Exception:
|
||||
pass
|
||||
return {"message": "Portfolio synchronized", "count": len(processed_codes), "user_id": ctx.user_id}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[portfolio_v2] sync 실패 user_id={ctx.user_id}: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=502, detail=f"Failed to sync portfolio: {e}")
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
|
||||
# ── POST /portfolio/v2/buy ─────────────────────────────────────────────────
|
||||
_inflight_v2: set[tuple] = set()
|
||||
_last_done_v2: dict[tuple, float] = {}
|
||||
_DUP_COOLDOWN_SEC = 120.0
|
||||
|
||||
|
||||
@router.post("/buy")
|
||||
async def buy_stock_v2(
|
||||
request: BuyRequest,
|
||||
ctx: UserContext = Depends(get_user_context),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""사용자 스코프 수동 매수 (`manual_buy._buy_stock_impl`의 `_v2` 버전)."""
|
||||
stock_code = request.stock_code.strip()
|
||||
if not _is_domestic(stock_code):
|
||||
raise HTTPException(status_code=400, detail="국내 종목(6자리 코드)만 수동 매수를 지원합니다")
|
||||
if settings.TRADING_HALTED:
|
||||
raise HTTPException(status_code=423, detail="TRADING_HALTED — 전체 매수 중단 상태")
|
||||
if ctx.user_id is None and not ctx.is_legacy:
|
||||
raise HTTPException(status_code=400, detail="사용자 컨텍스트를 확인할 수 없습니다")
|
||||
# L4: legacy 컨텍스트는 registry.legacy()가 조립 시점에 스냅샷한 필드값(최대 60초 TTL 지연)
|
||||
# 대신 `effective_auto_trade_enabled`로 settings.AUTO_TRADE_ENABLED를 즉시 재조회한다.
|
||||
if not ctx.is_legacy and not ctx.effective_auto_trade_enabled:
|
||||
raise HTTPException(status_code=423, detail="사용자 자동매매가 비활성화 상태입니다 (내 계좌 설정에서 활성화)")
|
||||
|
||||
dedup_key = (ctx.user_id, stock_code)
|
||||
if dedup_key in _inflight_v2:
|
||||
raise HTTPException(status_code=409, detail="같은 종목 매수 주문이 아직 처리 중입니다")
|
||||
since = time.monotonic() - _last_done_v2.get(dedup_key, -1e9)
|
||||
if since < _DUP_COOLDOWN_SEC:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"{int(_DUP_COOLDOWN_SEC - since)}초 전 같은 종목을 매수했습니다. 중복 주문 방지 쿨다운 중",
|
||||
)
|
||||
_inflight_v2.add(dedup_key)
|
||||
try:
|
||||
return await _buy_stock_impl_v2(request, stock_code, db, ctx)
|
||||
finally:
|
||||
_inflight_v2.discard(dedup_key)
|
||||
|
||||
|
||||
async def _buy_stock_impl_v2(request: BuyRequest, stock_code: str, db: AsyncSession, ctx: UserContext):
|
||||
try:
|
||||
price_data = await ctx.kis.get_current_price(stock_code)
|
||||
output = (price_data or {}).get("output") or {}
|
||||
current_price = float(output.get("stck_prpr") or 0)
|
||||
stock_name = request.stock_name or output.get("hts_kor_isnm") or stock_code
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"현재가 조회 실패: {e}")
|
||||
if current_price <= 0:
|
||||
raise HTTPException(status_code=404, detail=f"현재가를 가져올 수 없습니다: {stock_code}")
|
||||
|
||||
# ── 리스크 가드 (CLAUDE.md §5, 사용자 스코프) ─────────────────────────
|
||||
if current_price <= settings.MIN_PRICE_KRW:
|
||||
raise HTTPException(status_code=400, detail=f"가격 필터: {settings.MIN_PRICE_KRW:,}원 이하 종목 매수 금지")
|
||||
if await ctx.loss_tracker.is_banned_safe(stock_code):
|
||||
raise HTTPException(status_code=400, detail="연속 손실 진입 금지 종목입니다 (loss_tracker ban)")
|
||||
if not await ctx.mdd.can_open_new_position():
|
||||
raise HTTPException(status_code=400, detail="MDD 신규 진입 중단 상태입니다")
|
||||
|
||||
held = await db.execute(
|
||||
select(Portfolio).where(
|
||||
Portfolio.stock_code == stock_code, _user_scope_filter(Portfolio.user_id, ctx.user_id)
|
||||
)
|
||||
)
|
||||
holding = held.scalar_one_or_none()
|
||||
if holding is None:
|
||||
cnt = (await db.execute(
|
||||
select(func.count()).select_from(Portfolio).where(
|
||||
Portfolio.quantity > 0, _user_scope_filter(Portfolio.user_id, ctx.user_id)
|
||||
)
|
||||
)).scalar() or 0
|
||||
if cnt >= settings.MAX_HOLDINGS_COUNT:
|
||||
raise HTTPException(status_code=400, detail=f"최대 보유 종목 수({settings.MAX_HOLDINGS_COUNT}) 초과")
|
||||
|
||||
quantity = request.quantity
|
||||
order_price = float(request.price) if request.price else current_price
|
||||
if quantity is None:
|
||||
try:
|
||||
balance = await ctx.kis.get_balance()
|
||||
output2 = balance.get("output2") or [{}]
|
||||
b = output2[0] if isinstance(output2, list) else output2
|
||||
available_cash = float(b.get("dnca_tot_amt") or 0)
|
||||
total_assets = float(b.get("tot_evlu_amt") or 0)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"잔고 조회 실패: {e}")
|
||||
budget = min(total_assets * settings.MAX_POSITION_SIZE, available_cash)
|
||||
quantity = int(budget // order_price)
|
||||
if quantity < 1:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"예산 부족: 예산 {budget:,.0f}원 < 1주 {order_price:,.0f}원 (예수금 {available_cash:,.0f}원)",
|
||||
)
|
||||
|
||||
logger.info(f"🟢 [v2] user_id={ctx.user_id} 수동 매수 요청: {stock_name}({stock_code}) {quantity}주")
|
||||
|
||||
order_result = await safe_place_order_v2(
|
||||
ctx, stock_code=stock_code, order_type="BUY", quantity=quantity,
|
||||
price=request.price, is_domestic=True,
|
||||
)
|
||||
if not order_result or order_result.get("rt_cd") != "0":
|
||||
msg = order_result.get("msg1", "알 수 없는 오류") if order_result else "API 호출 실패"
|
||||
raise HTTPException(status_code=500, detail=f"매수 주문 실패: {msg}")
|
||||
order_id = (order_result.get("output") or {}).get("ODNO")
|
||||
|
||||
now = _kst_now()
|
||||
trade_history_payload = {
|
||||
"stock_code": stock_code,
|
||||
"stock_name": stock_name,
|
||||
"order_type": "BUY",
|
||||
"order_price": float(request.price) if request.price else None,
|
||||
"order_quantity": quantity,
|
||||
"executed_price": float(order_price),
|
||||
"executed_quantity": quantity,
|
||||
"total_amount": float(order_price * quantity),
|
||||
"status": "COMPLETED",
|
||||
"order_id": order_id,
|
||||
"message": "수동 매수 (v2, 사용자 스코프)",
|
||||
"strategy_type": "manual",
|
||||
"created_at": now,
|
||||
"executed_at": now,
|
||||
}
|
||||
# M3: legacy 컨텍스트는 user_id를 payload에 넣지 않는다 — 스윙/데이트레이딩 등
|
||||
# 기존 레거시 쓰기 경로가 전부 user_id 없이 쓰므로(NULL), 정합을 맞춰야
|
||||
# `_user_scope_filter`(NULL 매칭)로 같은 행이 조회된다.
|
||||
if not ctx.is_legacy:
|
||||
trade_history_payload["user_id"] = ctx.user_id
|
||||
await enqueue_trade_history(trade_history_payload)
|
||||
if holding is None:
|
||||
portfolio_payload = {
|
||||
"stock_code": stock_code,
|
||||
"stock_name": stock_name,
|
||||
"quantity": quantity,
|
||||
"average_price": order_price,
|
||||
"current_price": current_price,
|
||||
"profit_loss": 0,
|
||||
"profit_loss_rate": 0,
|
||||
"market": "KRX",
|
||||
"strategy_type": "manual",
|
||||
"currency_code": "KRW",
|
||||
"exchange_code": "KRX",
|
||||
}
|
||||
if not ctx.is_legacy:
|
||||
portfolio_payload["user_id"] = ctx.user_id
|
||||
await enqueue_portfolio_upsert(portfolio_payload)
|
||||
|
||||
_last_done_v2[(ctx.user_id, stock_code)] = time.monotonic()
|
||||
logger.info(f"✅ [v2] user_id={ctx.user_id} 수동 매수 주문 완료: {stock_name} {quantity}주 @ {order_price:,.0f}원")
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"{stock_name} {quantity}주 매수 주문 완료",
|
||||
"order_id": order_id,
|
||||
"quantity": quantity,
|
||||
"price": order_price,
|
||||
}
|
||||
|
||||
|
||||
# ── POST /portfolio/v2/sell ───────────────────────────────────────────────
|
||||
@router.post("/sell")
|
||||
async def sell_stock_v2(
|
||||
request: SellRequest,
|
||||
ctx: UserContext = Depends(get_user_context),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""사용자 스코프 수동 매도. 매도 대상은 본인 보유 종목만 조회(타 사용자 종목은 404)."""
|
||||
if ctx.user_id is None and not ctx.is_legacy:
|
||||
raise HTTPException(status_code=400, detail="사용자 컨텍스트를 확인할 수 없습니다")
|
||||
|
||||
stock_code = request.stock_code
|
||||
quantity = request.quantity
|
||||
price = request.price
|
||||
|
||||
result = await db.execute(
|
||||
select(Portfolio).where(
|
||||
Portfolio.stock_code == stock_code, _user_scope_filter(Portfolio.user_id, ctx.user_id)
|
||||
)
|
||||
)
|
||||
holding = result.scalar_one_or_none()
|
||||
if not holding:
|
||||
raise HTTPException(status_code=404, detail=f"보유하지 않은 종목입니다: {stock_code}")
|
||||
if holding.quantity < quantity:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"보유 수량 부족: {holding.quantity}주 보유 중, {quantity}주 매도 요청",
|
||||
)
|
||||
|
||||
stock_name = holding.stock_name
|
||||
original_holding_qty = holding.quantity
|
||||
average_price = holding.average_price
|
||||
|
||||
order_result = await safe_place_order_v2(
|
||||
ctx, stock_code=stock_code, order_type="SELL", quantity=quantity, price=price, is_domestic=True,
|
||||
)
|
||||
if not order_result or order_result.get("rt_cd") != "0":
|
||||
error_msg = order_result.get("msg1", "알 수 없는 오류") if order_result else "API 호출 실패"
|
||||
raise HTTPException(status_code=500, detail=f"매도 주문 실패: {error_msg}")
|
||||
|
||||
executed_price = price if price else float(holding.current_price or 0)
|
||||
profit_loss = (executed_price - float(average_price or 0)) * quantity
|
||||
profit_loss_rate = ((executed_price / float(average_price)) - 1) * 100 if average_price else 0.0
|
||||
|
||||
sell_history_payload = {
|
||||
"stock_code": stock_code,
|
||||
"stock_name": stock_name,
|
||||
"order_type": "SELL",
|
||||
"order_price": float(price) if price else None,
|
||||
"order_quantity": quantity,
|
||||
"executed_price": float(executed_price),
|
||||
"executed_quantity": quantity,
|
||||
"total_amount": float(executed_price * quantity),
|
||||
"status": "COMPLETED",
|
||||
"order_id": (order_result.get("output") or {}).get("ODNO"),
|
||||
"message": f"수동 청산 (v2): 손익 {profit_loss:+,.0f}원 ({profit_loss_rate:+.2f}%)",
|
||||
"exit_reason": f"사용자 수동 청산 (손익: {profit_loss:+,.0f}원, 손익률: {profit_loss_rate:+.2f}%)",
|
||||
"net_profit_loss": profit_loss,
|
||||
"profit_loss_rate": profit_loss_rate,
|
||||
"strategy_type": "manual",
|
||||
"created_at": _kst_now(),
|
||||
"executed_at": _kst_now(),
|
||||
}
|
||||
if not ctx.is_legacy:
|
||||
sell_history_payload["user_id"] = ctx.user_id
|
||||
await enqueue_trade_history(sell_history_payload)
|
||||
|
||||
if quantity >= original_holding_qty:
|
||||
await db.delete(holding)
|
||||
if ctx.is_legacy:
|
||||
# M3: legacy 스케줄러/기존 `/portfolio/sell`과 동일하게 user_id 없는 close 사용
|
||||
# (ActiveTrade.user_id == owner 필터로 NULL 행을 못 찾아 유령 포지션으로
|
||||
# 남는 결함 방지).
|
||||
await enqueue_close_active_trade(stock_code, strategy_name=holding.strategy_type)
|
||||
else:
|
||||
await enqueue_close_active_trade_v2(ctx.user_id, stock_code, strategy_name=holding.strategy_type)
|
||||
else:
|
||||
holding.quantity -= quantity
|
||||
holding.current_price = Decimal(str(executed_price))
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"✅ [v2] user_id={ctx.user_id} 수동 매도 완료: {stock_name} {quantity}주 @ {executed_price:,.0f}원")
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"{stock_name} {quantity}주 매도 주문 완료",
|
||||
"order_id": (order_result.get("output") or {}).get("ODNO"),
|
||||
"profit_loss": float(profit_loss),
|
||||
"profit_loss_rate": float(profit_loss_rate),
|
||||
}
|
||||
125
backend/app/api/endpoints/trades_v2.py
Normal file
125
backend/app/api/endpoints/trades_v2.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""사용자 스코프 거래 이력 조회 API (`_v2`, 멀티테넌트 1단계 §5.7).
|
||||
|
||||
기존 `trades.py`의 `GET /trades`(파라미터·손익 계산 로직)는 그대로 두고,
|
||||
`TradeHistory.user_id` 필터만 추가한 신규 라우터. 레거시 호환 규칙은
|
||||
`portfolio_v2.py`의 `_user_scope_filter`와 동일(`MULTI_TENANT_ENABLED=False`면
|
||||
`user_id == 대상 id` 또는 `user_id IS NULL` 모두 포함).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.tenant_auth import CurrentUser, get_current_user
|
||||
from app.models.models import TradeHistory
|
||||
from app.api.endpoints.portfolio_v2 import _resolve_target_user_id, _user_scope_filter
|
||||
from app.api.endpoints.trades import TradeHistoryResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=List[TradeHistoryResponse])
|
||||
async def get_trade_history_v2(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
stock_code: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
order_type: Optional[str] = None,
|
||||
days: int = 30,
|
||||
user_id: Optional[int] = None,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""사용자 스코프 거래 이력 조회 (`GET /trades`와 동일 파라미터 + 손익 파싱)."""
|
||||
target_user_id = await _resolve_target_user_id(user, user_id, db)
|
||||
|
||||
query = select(TradeHistory).where(_user_scope_filter(TradeHistory.user_id, target_user_id))
|
||||
|
||||
since_date = datetime.utcnow() - timedelta(days=days)
|
||||
query = query.where(TradeHistory.created_at >= since_date)
|
||||
|
||||
if stock_code:
|
||||
query = query.where(TradeHistory.stock_code == stock_code)
|
||||
if status:
|
||||
query = query.where(TradeHistory.status == status)
|
||||
if order_type:
|
||||
query = query.where(TradeHistory.order_type == order_type)
|
||||
|
||||
query = query.order_by(TradeHistory.created_at.desc()).offset(skip).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
trades = result.scalars().all()
|
||||
|
||||
trade_responses = []
|
||||
for trade in trades:
|
||||
response = TradeHistoryResponse.model_validate(trade)
|
||||
|
||||
if trade.order_type == "SELL" and trade.status in ("EXECUTED", "COMPLETED"):
|
||||
if hasattr(trade, "net_profit_loss") and trade.net_profit_loss is not None:
|
||||
response.profit_loss = trade.net_profit_loss
|
||||
if hasattr(trade, "profit_loss_rate") and trade.profit_loss_rate is not None:
|
||||
response.profit_loss_rate = float(trade.profit_loss_rate)
|
||||
|
||||
if response.profit_loss is None or response.profit_loss_rate is None:
|
||||
text_to_parse = trade.exit_reason or trade.message or ""
|
||||
if response.profit_loss is None:
|
||||
profit_match = re.search(r"손익:\s*([+-]?[\d,]+)원", text_to_parse)
|
||||
if profit_match:
|
||||
try:
|
||||
response.profit_loss = Decimal(profit_match.group(1).replace(",", ""))
|
||||
except Exception as e:
|
||||
logger.debug(f"[trades_v2] 손익 금액 파싱 실패: {e}")
|
||||
if response.profit_loss_rate is None:
|
||||
message_rate_match = re.search(r"손익률\s*([+-]?[\d.]+)%", trade.message or "")
|
||||
rate_match = message_rate_match or re.search(r"([+-]?[\d.]+)%", text_to_parse)
|
||||
if rate_match:
|
||||
try:
|
||||
response.profit_loss_rate = float(rate_match.group(1))
|
||||
except Exception as e:
|
||||
logger.debug(f"[trades_v2] 손익률 파싱 실패: {e}")
|
||||
|
||||
trade_responses.append(response)
|
||||
|
||||
return trade_responses
|
||||
|
||||
|
||||
# ── DELETE /trades/v2/pending ────────────────────────────────────────────
|
||||
@router.delete("/pending")
|
||||
async def delete_pending_trades_v2(
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""사용자 스코프 PENDING/FAILED 매수 시도 정리 (M6).
|
||||
|
||||
기존 `DELETE /trades/pending`(trades.py)은 `user_id` 조건 없이 전체 삭제라
|
||||
`MULTI_TENANT_ENABLED=True`에서 일반 사용자가 타 사용자의 이력을 지울 수 있었다.
|
||||
`_user_scope_filter`와 동일 규칙(MT=False면 본인 id 또는 NULL 행 포함, MT=True면
|
||||
본인 행만)을 적용해 legacy 컨텍스트(관리자/API 키, MT=False)는 기존 동작과 동일하고,
|
||||
일반 사용자는 본인 이력만 지운다.
|
||||
"""
|
||||
target_user_id = await _resolve_target_user_id(user, None, db)
|
||||
|
||||
query = select(TradeHistory).where(
|
||||
TradeHistory.status.in_(["PENDING", "FAILED"]),
|
||||
_user_scope_filter(TradeHistory.user_id, target_user_id),
|
||||
)
|
||||
result = await db.execute(query)
|
||||
pending_trades = result.scalars().all()
|
||||
count = len(pending_trades)
|
||||
|
||||
for trade in pending_trades:
|
||||
await db.delete(trade)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"✅ [v2] user_id={target_user_id} 정리 완료: {count}개의 PENDING/FAILED 매수 시도 삭제")
|
||||
|
||||
return {"deleted_count": count, "message": f"Cleaned up {count} pending/failed trades"}
|
||||
@@ -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 = "" # 비어있으면 부트스트랩 관리자 시드 건너뜀
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 데이터베이스 & 메시지 큐
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
95
backend/app/core/crypto.py
Normal file
95
backend/app/core/crypto.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""사용자 KIS 자격증명 암복호화 (멀티테넌트 1단계).
|
||||
|
||||
- 키 파생: PBKDF2-HMAC-SHA256(200_000 회) → Fernet 키.
|
||||
소스는 settings.CREDENTIALS_ENCRYPTION_KEY, 비어있으면 settings.SECRET_KEY 폴백.
|
||||
- 로테이션: CREDENTIALS_ENCRYPTION_KEY_PREVIOUS 가 설정되면 MultiFernet 으로
|
||||
구 키 복호화(읽기)만 허용, 암호화(쓰기)는 항상 현재 키(version 1)를 사용.
|
||||
버전 2단계 확장 시 key_version 값을 늘리고 _derive_key(version=N) 로 파생하면 됨.
|
||||
|
||||
사용:
|
||||
from app.core.crypto import encrypt, decrypt, current_key_version, is_encryption_configured
|
||||
token = encrypt("plain-app-key")
|
||||
plain = decrypt(token, key_version=1)
|
||||
"""
|
||||
import base64
|
||||
import functools
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken, MultiFernet
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_KDF_ITERATIONS = 200_000
|
||||
_DEFAULT_SECRET = "change-this-secret-key"
|
||||
|
||||
|
||||
class CredentialDecryptError(ValueError):
|
||||
"""자격증명 복호화 실패 — 키 로테이션/설정 변경 등 서버 측 원인 진단용 (L2).
|
||||
|
||||
기존 호출부가 `ValueError`를 기대할 수 있으므로 하위 호환을 위해 `ValueError`를 상속한다.
|
||||
"""
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=8)
|
||||
def _derive_key(secret: str, version: int) -> bytes:
|
||||
"""PBKDF2 로 Fernet 대칭키(urlsafe base64, 32바이트) 파생.
|
||||
|
||||
PBKDF2 200_000회 반복은 호출당 ~0.1~0.2초 CPU 연산이라(M4) (secret, version) 조합별로
|
||||
캐시한다 — secret 후보는 CREDENTIALS_ENCRYPTION_KEY/SECRET_KEY(+PREVIOUS) 뿐이라
|
||||
프로세스 수명 동안 조합 수가 매우 작다.
|
||||
"""
|
||||
salt = f"autostock-kis-cred-v{version}".encode()
|
||||
raw = hashlib.pbkdf2_hmac("sha256", secret.encode(), salt, _KDF_ITERATIONS)
|
||||
return base64.urlsafe_b64encode(raw)
|
||||
|
||||
|
||||
def _key_source() -> str:
|
||||
return settings.CREDENTIALS_ENCRYPTION_KEY or settings.SECRET_KEY
|
||||
|
||||
|
||||
def is_encryption_configured() -> bool:
|
||||
"""SECRET_KEY 가 기본값이거나 너무 짧으면(길이<32) 암호화 저장을 거부."""
|
||||
secret = _key_source()
|
||||
if secret == _DEFAULT_SECRET:
|
||||
return False
|
||||
if len(secret) < 32:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def current_key_version() -> int:
|
||||
"""현재 암호화에 사용하는 키 버전. 1단계는 항상 1(로테이션은 §9 2단계에서 증가)."""
|
||||
return 1
|
||||
|
||||
|
||||
def _fernet_for_write() -> Fernet:
|
||||
return Fernet(_derive_key(_key_source(), current_key_version()))
|
||||
|
||||
|
||||
def _fernet_for_read(key_version: int) -> MultiFernet:
|
||||
"""복호화용 — 현재 키 + (있으면) 이전 키 순으로 시도."""
|
||||
keys = [Fernet(_derive_key(_key_source(), key_version))]
|
||||
if settings.CREDENTIALS_ENCRYPTION_KEY_PREVIOUS:
|
||||
keys.append(Fernet(_derive_key(settings.CREDENTIALS_ENCRYPTION_KEY_PREVIOUS, key_version)))
|
||||
return MultiFernet(keys)
|
||||
|
||||
|
||||
def encrypt(plain: str) -> str:
|
||||
"""평문을 암호화해 저장용 문자열(토큰)로 반환. 항상 current_key_version() 기준."""
|
||||
if not is_encryption_configured():
|
||||
raise RuntimeError(
|
||||
"CREDENTIALS_ENCRYPTION_KEY/SECRET_KEY 미설정 또는 기본값 — 자격증명 암호화 저장 불가"
|
||||
)
|
||||
return _fernet_for_write().encrypt(plain.encode()).decode()
|
||||
|
||||
|
||||
def decrypt(token: str, key_version: int = 1) -> str:
|
||||
"""저장된 토큰을 평문으로 복호화. 실패 시 `CredentialDecryptError`(L2, `ValueError` 서브클래스)."""
|
||||
try:
|
||||
return _fernet_for_read(key_version).decrypt(token.encode()).decode()
|
||||
except InvalidToken as e:
|
||||
logger.error("[Crypto] 자격증명 복호화 실패 (key_version=%s)", key_version)
|
||||
raise CredentialDecryptError("자격증명 복호화 실패 — 암호화 키 불일치") from e
|
||||
27
backend/app/core/dt_utils.py
Normal file
27
backend/app/core/dt_utils.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""tz-aware/naive datetime 경계 정규화 헬퍼.
|
||||
|
||||
stock_bans / stock_bans_v2 등 일부 테이블의 DateTime 컬럼은 tz-naive인 반면,
|
||||
`loss_tracker.record_trade_result()`가 만드는 ban_until은 tz-aware(UTC)이다.
|
||||
asyncpg는 offset-naive/aware 혼용 시 DataError(저장)·TypeError(비교)를 던지므로,
|
||||
DB 경계(저장 직전/로드 직후)에서 반드시 이 헬퍼로 정규화한다.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def to_naive_utc(dt: Optional[datetime]) -> Optional[datetime]:
|
||||
"""DB 저장 직전 정규화: tz-aware → UTC 변환 후 tzinfo 제거. naive는 그대로 반환."""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is not None:
|
||||
return dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return dt
|
||||
|
||||
|
||||
def to_aware_utc(dt: Optional[datetime]) -> Optional[datetime]:
|
||||
"""DB에서 읽은 직후 정규화: tz-naive → UTC로 간주해 tzinfo 부착. aware는 그대로 반환."""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
134
backend/app/core/tenant_auth.py
Normal file
134
backend/app/core/tenant_auth.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""멀티테넌트 사용자 컨텍스트 의존성 (§5.4).
|
||||
|
||||
기존 `require_api_key`(security.py)는 라우터 등록에 그대로 유지하고, 이 모듈은
|
||||
그 위에서 "누구인지"를 해석하는 추가 레이어다. 엔드포인트 함수 시그니처에
|
||||
`user: CurrentUser = Depends(get_current_user)` 파라미터를 새로 추가하는 것은
|
||||
기존 함수 시그니처 변경에 해당하므로, 사용자 스코프가 필요한 엔드포인트는
|
||||
`_v2` 신규 함수/신규 라우터에서만 이 의존성을 사용할 것.
|
||||
|
||||
MULTI_TENANT_ENABLED=False 인 동안에도 세션 사용자의 `effective_user_id`는
|
||||
legacy owner id 를 반환해 하위 호환을 유지한다.
|
||||
"""
|
||||
import hmac
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.security import _get_api_key, require_api_key
|
||||
from app.models.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CurrentUser:
|
||||
"""인증된 호출 주체. API 키 호출자는 legacy owner 로 매핑된다."""
|
||||
|
||||
id: int | None # None = legacy owner 도 없음(관리자 미존재 상태)
|
||||
username: str
|
||||
is_admin: bool
|
||||
is_api_key: bool
|
||||
_legacy_owner_id: int | None = None # MULTI_TENANT_ENABLED=False 일 때 세션 사용자도 이 id 로 폴백
|
||||
|
||||
@property
|
||||
def effective_user_id(self) -> int | None:
|
||||
"""실제 DB 필터/컨텍스트 조회에 쓸 user_id.
|
||||
|
||||
MULTI_TENANT_ENABLED=True 면 본인 id 그대로, False 면 legacy owner id
|
||||
(본인이 곧 legacy owner 인 세션 사용자·API 키 호출자는 동일).
|
||||
"""
|
||||
if settings.MULTI_TENANT_ENABLED:
|
||||
return self.id
|
||||
return self._legacy_owner_id if self._legacy_owner_id is not None else self.id
|
||||
|
||||
|
||||
async def get_legacy_owner(db: AsyncSession) -> User | None:
|
||||
"""is_admin=True 이고 id 가 가장 작은 사용자(레거시 단일 계좌 소유자). 없으면 None."""
|
||||
result = await db.execute(
|
||||
select(User).where(User.is_admin.is_(True)).order_by(User.id.asc()).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _resolve_session_user(credential: str, db: AsyncSession) -> User | None:
|
||||
"""auth.py._resolve_user 와 동일 로직(순환 임포트 회피를 위해 여기서 재사용)."""
|
||||
from app.core.security import verify_session_token
|
||||
|
||||
username = verify_session_token(credential)
|
||||
if not username:
|
||||
return None
|
||||
result = await db.execute(select(User).where(User.username == username))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credential: str = Depends(require_api_key), db: AsyncSession = Depends(get_db)
|
||||
) -> CurrentUser:
|
||||
"""세션 토큰 → User. API 키 → legacy owner(없으면 id=None, is_api_key=True)."""
|
||||
legacy_owner = await get_legacy_owner(db)
|
||||
legacy_owner_id = legacy_owner.id if legacy_owner else None
|
||||
|
||||
if hmac.compare_digest(credential.encode(), _get_api_key().encode()):
|
||||
if legacy_owner is not None:
|
||||
return CurrentUser(
|
||||
id=legacy_owner.id,
|
||||
username=legacy_owner.username,
|
||||
is_admin=True,
|
||||
is_api_key=True,
|
||||
_legacy_owner_id=legacy_owner_id,
|
||||
)
|
||||
return CurrentUser(id=None, username="api-key", is_admin=True, is_api_key=True, _legacy_owner_id=None)
|
||||
|
||||
user = await _resolve_session_user(credential, db)
|
||||
if user is not None and user.is_active:
|
||||
return CurrentUser(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
is_admin=user.is_admin,
|
||||
is_api_key=False,
|
||||
_legacy_owner_id=legacy_owner_id,
|
||||
)
|
||||
|
||||
# 세션 사용자가 없거나(계정 삭제) 비활성(회수/미승인)이면 legacy owner 로 폴백하지 않고
|
||||
# 명시적으로 거부한다(H2) — require_api_key 는 JWT 서명/exp/aud 만 검증하고 is_active 를
|
||||
# 보지 않으므로, revoke 직후 토큰 만료 전까지는 이 분기가 유일한 방어선이다.
|
||||
if user is not None and not user.is_active:
|
||||
logger.warning("[TenantAuth] 비활성 사용자 세션 토큰 사용 시도: user=%s", user.username)
|
||||
else:
|
||||
logger.warning("[TenantAuth] require_api_key 통과 후 사용자 해석 실패(계정 삭제 등)")
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="세션이 유효하지 않습니다.")
|
||||
|
||||
|
||||
async def deny_non_owner_session_when_mt(user: CurrentUser = Depends(get_current_user)) -> None:
|
||||
"""M5: `MULTI_TENANT_ENABLED=True`일 때 legacy 전용 라우터(`portfolio`/`trades`/
|
||||
`manual_buy` 등, `/portfolio/v2`·`/trades/v2` 로 전환되지 않은 구 엔드포인트)를
|
||||
legacy owner가 아닌 세션 사용자가 호출하지 못하게 막는다.
|
||||
|
||||
- API 키 호출자(`is_api_key=True`, 예: MCP 서버)와 legacy owner 본인은 통과.
|
||||
- `MULTI_TENANT_ENABLED=False`(기본)이면 하위 호환을 위해 항상 통과.
|
||||
- 기존 `require_api_key` 의존성 위에 추가로 거는 것이므로 라우터 시그니처는 바꾸지 않는다.
|
||||
"""
|
||||
if not settings.MULTI_TENANT_ENABLED:
|
||||
return
|
||||
if user.is_api_key:
|
||||
return
|
||||
if user._legacy_owner_id is not None and user.id == user._legacy_owner_id:
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="멀티테넌트 모드에서는 본인 계좌 API(/portfolio/v2, /trades/v2)를 사용하세요.",
|
||||
)
|
||||
|
||||
|
||||
async def require_current_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
||||
"""관리자 전용 `_v2` 엔드포인트에서 사용. auth.py.require_admin 과 별도(반환 타입이 CurrentUser)."""
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
if not user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="관리자 권한이 필요합니다.")
|
||||
return user
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
"""해당 종목이 진입 금지 상태인지 확인"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
5
backend/app/services/tenant/__init__.py
Normal file
5
backend/app/services/tenant/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""멀티테넌트(사용자별 매매) 서비스 계층 — 1단계.
|
||||
|
||||
- bootstrap: 앱 최초 기동 시 관리자 계정·KIS 자격증명 이관 시드
|
||||
- (2단계 예정) context.py / registry.py / kis_pool.py
|
||||
"""
|
||||
174
backend/app/services/tenant/bootstrap.py
Normal file
174
backend/app/services/tenant/bootstrap.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""앱 기동 시 멀티테넌트 부트스트랩 (관리자 시드 + legacy 자격증명 이관 + 백필).
|
||||
|
||||
`main.py` lifespan 에서 `create_all()` 직후 1회 호출된다(§ CLAUDE.md 예외: 부트스트랩
|
||||
시드·자격증명 저장은 단일 트랜잭션 직접 커밋 허용 — 실시간 계층 아님).
|
||||
|
||||
모든 단계는 독립적으로 try/except 로 감싸 실패해도 앱 부팅을 막지 않는다.
|
||||
alembic 0011 과 동일한 백필 로직을 idempotent 하게 재수행 — alembic 이 CLI 로만
|
||||
실행되는 배포 환경에서도(예: 최초 로그인 전) 앱 기동 시 스스로 보정한다.
|
||||
"""
|
||||
import logging
|
||||
|
||||
import bcrypt
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.crypto import current_key_version, encrypt, is_encryption_configured
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.models import User, UserKisCredential
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# alembic 0011 과 동일한 백필 대상 테이블 목록
|
||||
_BACKFILL_TABLES = [
|
||||
"portfolio",
|
||||
"trade_history",
|
||||
"active_trades",
|
||||
"trade_journals",
|
||||
"trading_sessions",
|
||||
"mcp_tool_calls",
|
||||
"claude_analysis_log",
|
||||
]
|
||||
|
||||
|
||||
def _hash_password(password: str) -> str:
|
||||
try:
|
||||
from app.api.endpoints.auth import _hash_password as _auth_hash_password
|
||||
|
||||
return _auth_hash_password(password)
|
||||
except ImportError:
|
||||
# 순환 임포트 등으로 재사용 불가할 때만 동일 로직 복제 (auth.py:_hash_password 와 동치)
|
||||
return bcrypt.hashpw(password.encode()[:72], bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
async def _seed_admin_if_empty(session) -> None:
|
||||
"""users 테이블이 비어 있고 BOOTSTRAP_ADMIN_PASSWORD 가 설정되어 있으면 관리자 시드."""
|
||||
if not settings.BOOTSTRAP_ADMIN_PASSWORD:
|
||||
return
|
||||
total = (await session.execute(select(User))).scalars().first()
|
||||
if total is not None:
|
||||
return # 이미 사용자 존재 — 시드 불필요
|
||||
|
||||
user = User(
|
||||
username=settings.BOOTSTRAP_ADMIN_USERNAME,
|
||||
password_hash=_hash_password(settings.BOOTSTRAP_ADMIN_PASSWORD),
|
||||
is_active=True,
|
||||
is_admin=True,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
logger.info(f"[Bootstrap] 부트스트랩 관리자 계정 생성 완료: {settings.BOOTSTRAP_ADMIN_USERNAME}")
|
||||
|
||||
|
||||
async def _get_legacy_owner(session) -> User | None:
|
||||
result = await session.execute(
|
||||
select(User).where(User.is_admin.is_(True)).order_by(User.id.asc()).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _seed_legacy_kis_credential(session, owner: User) -> None:
|
||||
"""legacy owner 에게 .env KIS 자격증명이 아직 이관되지 않았으면 암호화해 저장."""
|
||||
existing = await session.execute(
|
||||
select(UserKisCredential).where(UserKisCredential.user_id == owner.id)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
return # 이미 이관됨
|
||||
|
||||
app_key = settings.KOREA_INVESTMENT_APP_KEY
|
||||
app_secret = settings.KOREA_INVESTMENT_APP_SECRET
|
||||
account_no = (settings.KOREA_INVESTMENT_ACCOUNT_NUMBER or "").replace("-", "")
|
||||
|
||||
if not (app_key and app_secret and account_no):
|
||||
logger.info("[Bootstrap] .env KIS 자격증명 미설정 — legacy 자격증명 이관 스킵")
|
||||
return
|
||||
if not is_encryption_configured():
|
||||
logger.warning("[Bootstrap] CREDENTIALS_ENCRYPTION_KEY/SECRET_KEY 미설정 — legacy 자격증명 이관 스킵")
|
||||
return
|
||||
|
||||
import hashlib
|
||||
|
||||
app_key_hash = hashlib.sha256(app_key.encode()).hexdigest()
|
||||
dup = await session.execute(
|
||||
select(UserKisCredential).where(UserKisCredential.app_key_hash == app_key_hash)
|
||||
)
|
||||
if dup.scalar_one_or_none() is not None:
|
||||
logger.warning("[Bootstrap] 동일 앱키가 이미 등록되어 있어 legacy 이관 스킵")
|
||||
return
|
||||
|
||||
account_product_code = account_no[8:10] if len(account_no) >= 10 else "01"
|
||||
|
||||
credential = UserKisCredential(
|
||||
user_id=owner.id,
|
||||
mode=settings.KIS_MODE,
|
||||
app_key_enc=encrypt(app_key),
|
||||
app_secret_enc=encrypt(app_secret),
|
||||
account_no_enc=encrypt(account_no),
|
||||
account_product_code=account_product_code,
|
||||
app_key_hash=app_key_hash,
|
||||
app_key_hint=app_key[:6],
|
||||
is_enabled=True,
|
||||
auto_trade_enabled=settings.AUTO_TRADE_ENABLED,
|
||||
last_verified_at=None,
|
||||
key_version=current_key_version(),
|
||||
)
|
||||
session.add(credential)
|
||||
await session.commit()
|
||||
logger.info(f"[Bootstrap] legacy KIS 자격증명 이관 완료 (owner={owner.username}, app_key={app_key[:6]}…)")
|
||||
|
||||
|
||||
async def _backfill_legacy_owner_ids(session, owner_id: int) -> None:
|
||||
"""alembic 0011 과 동일한 백필을 idempotent 하게 재수행 (user_id IS NULL 인 행만 대상).
|
||||
|
||||
M8: PostgreSQL은 트랜잭션 내 한 문장이 실패(예: 테이블/컬럼 미존재)하면 세션이
|
||||
`InFailedSqlTransaction` 상태가 되어 이후 모든 문장이 연쇄 실패한다. 문장별로
|
||||
`session.begin_nested()`(SAVEPOINT)로 감싸 한 테이블의 실패가 나머지 테이블
|
||||
백필을 막지 않게 한다.
|
||||
"""
|
||||
for table in _BACKFILL_TABLES:
|
||||
try:
|
||||
async with session.begin_nested():
|
||||
await session.execute(
|
||||
text(f"UPDATE {table} SET user_id = :owner WHERE user_id IS NULL"),
|
||||
{"owner": owner_id},
|
||||
)
|
||||
except Exception as e: # 컬럼/테이블이 아직 없는 초기 상태 등 — 개별 실패는 무시하고 계속
|
||||
logger.debug(f"[Bootstrap] {table} 백필 스킵: {e}")
|
||||
|
||||
try:
|
||||
async with session.begin_nested():
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO stock_bans_v2 (user_id, stock_code, consecutive_losses, ban_until, updated_at)
|
||||
SELECT :owner, stock_code, consecutive_losses, ban_until, updated_at
|
||||
FROM stock_bans
|
||||
ON CONFLICT (user_id, stock_code) DO NOTHING
|
||||
"""
|
||||
),
|
||||
{"owner": owner_id},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[Bootstrap] stock_bans_v2 백필 스킵: {e}")
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def ensure_bootstrap_admin() -> None:
|
||||
"""앱 기동 시 1회 호출. 실패해도 예외를 전파하지 않는다(로그만 남김)."""
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
await _seed_admin_if_empty(session)
|
||||
except Exception as e:
|
||||
logger.error(f"[Bootstrap] 관리자 계정 시드 실패 (non-critical): {e}")
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
owner = await _get_legacy_owner(session)
|
||||
if owner is None:
|
||||
logger.info("[Bootstrap] 관리자 계정 없음 — legacy 자격증명 이관/백필 스킵")
|
||||
return
|
||||
await _seed_legacy_kis_credential(session, owner)
|
||||
await _backfill_legacy_owner_ids(session, owner.id)
|
||||
except Exception as e:
|
||||
logger.error(f"[Bootstrap] legacy 자격증명 이관/백필 실패 (non-critical): {e}")
|
||||
62
backend/app/services/tenant/context.py
Normal file
62
backend/app/services/tenant/context.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""멀티테넌트 사용자 실행 컨텍스트 (설계 문서 §2, §5.6).
|
||||
|
||||
`UserContext`는 사용자 1명의 매매 실행에 필요한 모든 상태(KIS 클라이언트,
|
||||
리스크 매니저, 데이트레이딩 상태 등)를 한데 묶는다. 실제 조립(레지스트리)은
|
||||
dev-team/risk-monitor-agent가 담당하는 `registry.py`에서 이루어지며, 이 파일은
|
||||
데이터 구조만 정의한다 — import 순환을 피하기 위해 mdd/loss_tracker/dt_state는
|
||||
구체 타입을 import하지 않고 `Any`로 둔다.
|
||||
|
||||
이 모듈은 DB 세션을 열지 않는다(자격증명 복호화는 호출 측이 수행해 넘겨준다).
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.services.korea_investment_api import KoreaInvestmentAPI
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserContext:
|
||||
"""사용자 1명의 매매 실행 컨텍스트.
|
||||
|
||||
Attributes:
|
||||
user_id: `users.id`. legacy(단일 계좌) 컨텍스트는 legacy owner id 또는 None.
|
||||
username: 표시/로그용.
|
||||
mode: "virtual" | "real" — `kis.base_url`과 동일해야 함.
|
||||
kis: 이 사용자의 주문·잔고 전용 `KoreaInvestmentAPI` 인스턴스
|
||||
(`KoreaInvestmentAPI.for_user(...)` 또는 legacy 전역 `korea_investment_api`).
|
||||
mdd: 사용자별 `MDDManager` 인스턴스 (risk-monitor-agent 담당, 타입은 순환 방지 위해 Any).
|
||||
loss_tracker: 사용자별 `ConsecutiveLossTracker` 인스턴스 (Any, 위와 동일 이유).
|
||||
dt_state: 사용자별 `DayTradingState` 인스턴스 (2단계에서 사용, 1단계는 None 허용).
|
||||
portfolio_cache: 사용자별 `PortfolioCache` 인스턴스 (2단계, 1단계는 None 허용).
|
||||
auto_trade_enabled: 사용자 단위 자동매매 on/off.
|
||||
is_legacy: True면 기존 전역 싱글톤(korea_investment_api/mdd_manager/loss_tracker)을
|
||||
그대로 감싼 컨텍스트 — Redis 락/키 네임스페이스가 하위 호환 경로(user_id 미포함)를 쓴다.
|
||||
"""
|
||||
|
||||
user_id: Optional[int]
|
||||
username: str
|
||||
mode: str
|
||||
kis: KoreaInvestmentAPI
|
||||
mdd: Any
|
||||
loss_tracker: Any
|
||||
dt_state: Any = None
|
||||
portfolio_cache: Any = None
|
||||
auto_trade_enabled: bool = False
|
||||
is_legacy: bool = False
|
||||
|
||||
@property
|
||||
def effective_auto_trade_enabled(self) -> bool:
|
||||
"""`auto_trade_enabled`의 실시간 버전.
|
||||
|
||||
legacy 컨텍스트는 `registry.legacy()`가 조립 시점에 `settings.AUTO_TRADE_ENABLED`를
|
||||
스냅샷해 최대 60초(TTL) 동안 고정한다(L4) — 런타임에 `.env`/설정을 토글해도
|
||||
캐시가 갱신되기 전까지 반영되지 않는 문제. legacy 컨텍스트에서는 필드 대신 이
|
||||
프로퍼티로 `settings.AUTO_TRADE_ENABLED`를 매번 다시 읽어 즉시 반영한다.
|
||||
사용자 컨텍스트(is_legacy=False)는 DB에 저장된 사용자별 토글이 필드값이므로
|
||||
그대로 반환한다. portfolio_v2 buy 가드는 `ctx.auto_trade_enabled` 대신 이
|
||||
프로퍼티 사용을 권장.
|
||||
"""
|
||||
if self.is_legacy:
|
||||
from app.core.config import settings
|
||||
return settings.AUTO_TRADE_ENABLED
|
||||
return self.auto_trade_enabled
|
||||
75
backend/app/services/tenant/kis_pool.py
Normal file
75
backend/app/services/tenant/kis_pool.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""사용자별 `KoreaInvestmentAPI` 인스턴스 풀 (설계 문서 §5.5).
|
||||
|
||||
- 자격증명 복호화는 호출 측(Registry/credentials API, B그룹)이 수행해
|
||||
평문(app_key/app_secret/account_no)을 넘겨준다 — 이 모듈은 DB 세션을 열지 않는다.
|
||||
- 캐시 키는 `user_id` 하나뿐이다(1단계는 사용자당 자격증명 1세트 전제, §3.1).
|
||||
자격증명이 바뀌면 호출 측이 반드시 `invalidate(user_id)`를 먼저 호출해야 한다
|
||||
(여기서는 캐시가 있으면 그대로 재사용 — 자격증명 변경 감지를 하지 않음).
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
from app.services.korea_investment_api import KoreaInvestmentAPI
|
||||
from app.services import korea_investment_api as _kis_module
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KISClientPool:
|
||||
"""사용자별 `KoreaInvestmentAPI` 인스턴스 캐시 (asyncio.Lock으로 동시 생성 방지)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._instances: Dict[int, KoreaInvestmentAPI] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get_or_create(
|
||||
self,
|
||||
user_id: int,
|
||||
app_key: str,
|
||||
app_secret: str,
|
||||
account_no: str,
|
||||
mode: str,
|
||||
) -> KoreaInvestmentAPI:
|
||||
"""캐시에 있으면 반환, 없으면 `KoreaInvestmentAPI.for_user(...)`로 생성 후 캐시."""
|
||||
existing = self._instances.get(user_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
async with self._lock:
|
||||
existing = self._instances.get(user_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
instance = KoreaInvestmentAPI.for_user(
|
||||
user_id=user_id,
|
||||
app_key=app_key,
|
||||
app_secret=app_secret,
|
||||
account_no=account_no,
|
||||
mode=mode,
|
||||
)
|
||||
self._instances[user_id] = instance
|
||||
return instance
|
||||
|
||||
async def invalidate(self, user_id: int) -> None:
|
||||
"""캐시된 인스턴스를 정리(httpx 클라이언트 close)하고 제거한다.
|
||||
|
||||
자격증명 교체/삭제 시 credentials API(B그룹)가 호출해야 한다.
|
||||
"""
|
||||
async with self._lock:
|
||||
instance = self._instances.pop(user_id, None)
|
||||
if instance is not None:
|
||||
try:
|
||||
await instance.close()
|
||||
except Exception as e:
|
||||
logger.warning("⚠️ KISClientPool.invalidate: user_id=%s close 실패: %s", user_id, e)
|
||||
|
||||
def get_legacy(self) -> KoreaInvestmentAPI:
|
||||
"""전역 legacy 싱글톤 반환 (`MULTI_TENANT_ENABLED=False` 또는 legacy owner 경로용)."""
|
||||
return _kis_module.korea_investment_api
|
||||
|
||||
def active_user_ids(self) -> list:
|
||||
"""현재 캐시된(생성된) 사용자 id 목록."""
|
||||
return list(self._instances.keys())
|
||||
|
||||
|
||||
# 모듈 싱글톤
|
||||
kis_client_pool = KISClientPool()
|
||||
251
backend/app/services/tenant/registry.py
Normal file
251
backend/app/services/tenant/registry.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""사용자 실행 컨텍스트 레지스트리 (설계 문서 §2, §5.6).
|
||||
|
||||
`UserContext` 조립(자격증명 복호화 → KIS 클라이언트 풀 → 리스크 매니저 팩토리 →
|
||||
상태 복원)을 한곳에 모아 캐싱한다. FastAPI 의존성 `get_user_context()`도 여기서
|
||||
제공한다(트레이딩 실행/엔드포인트 양쪽에서 재사용).
|
||||
|
||||
- DB 세션은 항상 호출 측이 넘겨준다 — 이 모듈은 `AsyncSessionLocal()`을 직접
|
||||
열지 않는다(§CLAUDE.md 아키텍처 원칙 1). `iter_active()`만 예외적으로 스케줄러가
|
||||
세션을 만들어 넘겨주는 것을 전제로 `db` 인자를 필수로 받는다.
|
||||
- 복호화 실패 등 개별 사용자 오류는 그 사용자만 None/스킵 처리하고 다른 사용자에
|
||||
전파하지 않는다.
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.crypto import CredentialDecryptError, decrypt
|
||||
from app.core.database import get_db
|
||||
from app.core.tenant_auth import CurrentUser, get_current_user, get_legacy_owner
|
||||
from app.models.models import UserKisCredential
|
||||
from app.services.tenant.context import UserContext
|
||||
from app.services.tenant.kis_pool import kis_client_pool
|
||||
from app.services.tenant.risk_factory import (
|
||||
create_loss_tracker_for_user,
|
||||
create_mdd_manager_for_user,
|
||||
load_user_risk_state,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LEGACY_OWNER_TTL_SEC = 60
|
||||
_ACTIVE_LIST_TTL_SEC = 60
|
||||
|
||||
|
||||
class UserContextRegistry:
|
||||
"""사용자별 `UserContext` 캐시 + 조립 로직."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._contexts: dict[int, UserContext] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
self._legacy_ctx: UserContext | None = None
|
||||
self._legacy_owner_id: int | None = None
|
||||
self._legacy_checked_at: float = 0.0
|
||||
self._legacy_lock = asyncio.Lock()
|
||||
self._legacy_owner_confirmed: bool = False
|
||||
|
||||
self._active_cache: list[UserContext] | None = None
|
||||
self._active_cached_at: float = 0.0
|
||||
|
||||
# ── 사용자별 컨텍스트 조립 ────────────────────────────────────────────
|
||||
async def get(self, user_id: int, db: AsyncSession) -> UserContext | None:
|
||||
"""캐시 히트면 즉시 반환, 미스면 DB에서 자격증명을 읽어 새로 조립한다.
|
||||
|
||||
M4: `asyncio.Lock` 은 캐시 dict 갱신 구간(더블체크 패턴)만 감싼다 — DB I/O,
|
||||
PBKDF2 복호화(스레드로 위임), KIS 클라이언트 생성, Redis/DB 리스크 상태 복원은
|
||||
모두 락 밖에서 수행해 이벤트 루프 직렬화를 피한다. 동시에 같은 user_id 로 호출되면
|
||||
중복 조립이 일어날 수 있으나(락 밖 조립) 마지막에 dict 갱신만 락으로 보호하고
|
||||
먼저 캐시된 인스턴스를 우선한다.
|
||||
"""
|
||||
cached = self._contexts.get(user_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
result = await db.execute(
|
||||
select(UserKisCredential).where(UserKisCredential.user_id == user_id)
|
||||
)
|
||||
cred = result.scalar_one_or_none()
|
||||
if cred is None or not cred.is_enabled:
|
||||
logger.debug(f"[UserContextRegistry] user={user_id} 자격증명 없음/비활성 — None 반환")
|
||||
return None
|
||||
|
||||
try:
|
||||
app_key = await asyncio.to_thread(decrypt, cred.app_key_enc, key_version=cred.key_version)
|
||||
app_secret = await asyncio.to_thread(decrypt, cred.app_secret_enc, key_version=cred.key_version)
|
||||
account_no = await asyncio.to_thread(decrypt, cred.account_no_enc, key_version=cred.key_version)
|
||||
except CredentialDecryptError as e:
|
||||
logger.error(f"[UserContextRegistry] user={user_id} 자격증명 복호화 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=400, detail="자격증명 복호화 실패 — 서버 키 변경 여부 확인",
|
||||
) from e
|
||||
|
||||
try:
|
||||
kis = await kis_client_pool.get_or_create(
|
||||
user_id=user_id,
|
||||
app_key=app_key,
|
||||
app_secret=app_secret,
|
||||
account_no=account_no,
|
||||
mode=cred.mode,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[UserContextRegistry] user={user_id} KIS 클라이언트 생성 실패: {e}")
|
||||
return None
|
||||
|
||||
mdd = create_mdd_manager_for_user(user_id)
|
||||
loss_tracker = create_loss_tracker_for_user(user_id)
|
||||
await load_user_risk_state(user_id, mdd, loss_tracker, db)
|
||||
|
||||
ctx = UserContext(
|
||||
user_id=user_id,
|
||||
username="",
|
||||
mode=cred.mode,
|
||||
kis=kis,
|
||||
mdd=mdd,
|
||||
loss_tracker=loss_tracker,
|
||||
auto_trade_enabled=bool(cred.auto_trade_enabled),
|
||||
is_legacy=False,
|
||||
)
|
||||
async with self._lock:
|
||||
existing = self._contexts.get(user_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
self._contexts[user_id] = ctx
|
||||
return ctx
|
||||
|
||||
# ── legacy(단일 계좌) 컨텍스트 ────────────────────────────────────────
|
||||
async def legacy(self, db: AsyncSession | None = None) -> UserContext:
|
||||
"""기존 전역 싱글톤(`korea_investment_api`/`mdd_manager`/`loss_tracker`)을 감싼 컨텍스트.
|
||||
|
||||
owner id는 60초 TTL로 재확인한다(관리자 계정이 나중에 생기는 초기 부팅 구간 대응).
|
||||
owner 가 아직 없으면(관리자 미존재) `user_id=None`으로 반환하고 TTL 캐시를 걸지
|
||||
않는다(M1) — `users.id=0`은 존재하지 않는 FK 위반 값이라 0 대신 None 으로 통일한다.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
if (
|
||||
self._legacy_ctx is not None
|
||||
and self._legacy_owner_confirmed
|
||||
and (now - self._legacy_checked_at) < _LEGACY_OWNER_TTL_SEC
|
||||
):
|
||||
return self._legacy_ctx
|
||||
|
||||
async with self._legacy_lock:
|
||||
now = time.monotonic()
|
||||
if (
|
||||
self._legacy_ctx is not None
|
||||
and self._legacy_owner_confirmed
|
||||
and (now - self._legacy_checked_at) < _LEGACY_OWNER_TTL_SEC
|
||||
):
|
||||
return self._legacy_ctx
|
||||
|
||||
owner_id: int | None = None
|
||||
if db is not None:
|
||||
try:
|
||||
owner = await get_legacy_owner(db)
|
||||
if owner is not None:
|
||||
owner_id = owner.id
|
||||
except Exception as e:
|
||||
logger.warning(f"[UserContextRegistry] legacy owner 조회 실패: {e}")
|
||||
|
||||
from app.services.korea_investment_api import korea_investment_api
|
||||
from app.services.mdd_manager import mdd_manager
|
||||
from app.services.loss_tracker import loss_tracker
|
||||
|
||||
self._legacy_ctx = UserContext(
|
||||
user_id=owner_id,
|
||||
username="legacy",
|
||||
mode=settings.KIS_MODE,
|
||||
kis=korea_investment_api,
|
||||
mdd=mdd_manager,
|
||||
loss_tracker=loss_tracker,
|
||||
auto_trade_enabled=settings.AUTO_TRADE_ENABLED,
|
||||
is_legacy=True,
|
||||
)
|
||||
self._legacy_owner_id = owner_id
|
||||
self._legacy_owner_confirmed = owner_id is not None
|
||||
self._legacy_checked_at = now
|
||||
return self._legacy_ctx
|
||||
|
||||
# ── 인증 사용자 → 컨텍스트 해석 ───────────────────────────────────────
|
||||
async def resolve(self, current_user: CurrentUser, db: AsyncSession) -> UserContext:
|
||||
"""`CurrentUser` → `UserContext`. 실패 시 400 HTTPException."""
|
||||
legacy_ctx = await self.legacy(db)
|
||||
|
||||
use_legacy = (
|
||||
not settings.MULTI_TENANT_ENABLED
|
||||
or current_user.is_api_key
|
||||
or (current_user.id is not None and current_user.id == legacy_ctx.user_id)
|
||||
)
|
||||
if use_legacy:
|
||||
return legacy_ctx
|
||||
|
||||
if current_user.id is None:
|
||||
raise HTTPException(status_code=400, detail="KIS 계좌가 등록·검증되지 않았습니다. 내 계좌 설정에서 등록하세요")
|
||||
|
||||
ctx = await self.get(current_user.id, db)
|
||||
if ctx is None:
|
||||
raise HTTPException(status_code=400, detail="KIS 계좌가 등록·검증되지 않았습니다. 내 계좌 설정에서 등록하세요")
|
||||
return ctx
|
||||
|
||||
# ── 2단계 스케줄러용: 활성 사용자 전체 순회 ───────────────────────────
|
||||
async def iter_active(self, db: AsyncSession) -> list[UserContext]:
|
||||
"""`is_enabled and auto_trade_enabled` 인 모든 사용자의 컨텍스트 (60초 TTL 캐시).
|
||||
|
||||
지금은 조립 로직만 제공 — 실제 2단계 스케줄러 연결은 이번 범위 밖.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
if self._active_cache is not None and (now - self._active_cached_at) < _ACTIVE_LIST_TTL_SEC:
|
||||
return self._active_cache
|
||||
|
||||
result = await db.execute(
|
||||
select(UserKisCredential).where(
|
||||
UserKisCredential.is_enabled.is_(True),
|
||||
UserKisCredential.auto_trade_enabled.is_(True),
|
||||
)
|
||||
)
|
||||
creds = result.scalars().all()
|
||||
|
||||
contexts: list[UserContext] = []
|
||||
for cred in creds:
|
||||
try:
|
||||
ctx = await self.get(cred.user_id, db)
|
||||
except Exception as e:
|
||||
logger.error(f"[UserContextRegistry] iter_active: user={cred.user_id} 조립 실패: {e}")
|
||||
ctx = None
|
||||
if ctx is not None:
|
||||
contexts.append(ctx)
|
||||
|
||||
self._active_cache = contexts
|
||||
self._active_cached_at = now
|
||||
return contexts
|
||||
|
||||
# ── 캐시 무효화 ───────────────────────────────────────────────────────
|
||||
async def invalidate(self, user_id: int) -> None:
|
||||
"""자격증명 변경/삭제 시 credentials API가 호출 — 컨텍스트 캐시 + KIS 풀 정리."""
|
||||
async with self._lock:
|
||||
self._contexts.pop(user_id, None)
|
||||
await kis_client_pool.invalidate(user_id)
|
||||
self._active_cache = None
|
||||
|
||||
async def invalidate_all(self) -> None:
|
||||
async with self._lock:
|
||||
self._contexts.clear()
|
||||
self._active_cache = None
|
||||
|
||||
|
||||
# 모듈 싱글톤
|
||||
user_context_registry = UserContextRegistry()
|
||||
|
||||
|
||||
async def get_user_context(
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> UserContext:
|
||||
"""FastAPI 의존성 — 세션/API 키 인증 사용자를 `UserContext`로 해석."""
|
||||
return await user_context_registry.resolve(user, db)
|
||||
71
backend/app/services/tenant/risk_factory.py
Normal file
71
backend/app/services/tenant/risk_factory.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""사용자 스코프 리스크 매니저 팩토리 (설계 문서 §5.6).
|
||||
|
||||
`UserContextRegistry`(dev-team/trading-agent 조립 담당)가 사용자 컨텍스트를
|
||||
만들 때 이 팩토리로 `MDDManager`/`ConsecutiveLossTracker` 인스턴스를 새로
|
||||
생성하고 영속화 상태(Redis `mdd:state:{user_id}`, `stock_bans_v2`)를 복원한다.
|
||||
|
||||
기존 전역 싱글톤(`app.services.mdd_manager.mdd_manager`,
|
||||
`app.services.loss_tracker.loss_tracker`)은 legacy 컨텍스트 전용으로 그대로
|
||||
남겨두고 이 모듈에서 재사용하지 않는다 — 여러 사용자가 동일 인스턴스를 공유하면
|
||||
peak_balance/연속손실 카운트가 뒤섞이기 때문이다.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from app.services.mdd_manager import MDDManager
|
||||
from app.services.loss_tracker import ConsecutiveLossTracker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_mdd_manager_for_user(user_id: int) -> MDDManager:
|
||||
"""사용자 전용 `MDDManager` 신규 인스턴스 생성.
|
||||
|
||||
peak_balance/is_paused 등은 기본값(0/False)에서 시작 — 실제 복원은
|
||||
`load_user_risk_state()`가 `load_from_redis_v2(user_id)`로 수행한다.
|
||||
"""
|
||||
return MDDManager()
|
||||
|
||||
|
||||
def create_loss_tracker_for_user(user_id: int) -> ConsecutiveLossTracker:
|
||||
"""사용자 전용 `ConsecutiveLossTracker` 신규 인스턴스 생성.
|
||||
|
||||
`ban_count`/`ban_calendar_days`는 전역 설정(§CLAUDE.md 5.1: 3연패/42일)을
|
||||
그대로 따른다 — 사용자별 오버라이드는 이번 범위 밖.
|
||||
인스턴스의 `user_id` 속성을 세팅해 두면 호출 측(스케줄러/엔드포인트)이
|
||||
`ctx.loss_tracker.save_to_db_v2(ctx.loss_tracker.user_id, stock_code)`처럼
|
||||
컨텍스트에서 user_id를 다시 꺼낼 필요 없이 인스턴스에서 바로 참조할 수 있다.
|
||||
"""
|
||||
try:
|
||||
from app.core.config import settings
|
||||
tracker = ConsecutiveLossTracker(
|
||||
ban_count=settings.CONSECUTIVE_LOSS_BAN_COUNT,
|
||||
ban_calendar_days=42,
|
||||
)
|
||||
except Exception:
|
||||
tracker = ConsecutiveLossTracker()
|
||||
tracker.user_id = user_id
|
||||
return tracker
|
||||
|
||||
|
||||
async def load_user_risk_state(
|
||||
ctx_user_id: int,
|
||||
mdd: MDDManager,
|
||||
tracker: ConsecutiveLossTracker,
|
||||
db,
|
||||
) -> None:
|
||||
"""사용자 컨텍스트 생성 직후 1회 호출 — Redis(MDD) + DB(ban) 상태 복원.
|
||||
|
||||
두 복원 모두 실패해도 예외를 전파하지 않는다(각 `_v2` 메서드 내부에서
|
||||
이미 try/except 처리됨) — 신규 사용자는 그냥 초기 상태로 시작한다.
|
||||
"""
|
||||
try:
|
||||
restored = await mdd.load_from_redis_v2(ctx_user_id)
|
||||
if not restored:
|
||||
logger.debug(f"[risk_factory] user={ctx_user_id} MDD 상태 없음 — 초기값으로 시작")
|
||||
except Exception as e:
|
||||
logger.warning(f"[risk_factory] user={ctx_user_id} MDD 복원 실패: {e}")
|
||||
|
||||
try:
|
||||
await tracker.load_from_db_v2(db, ctx_user_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"[risk_factory] user={ctx_user_id} loss_tracker 복원 실패: {e}")
|
||||
@@ -208,6 +208,111 @@ async def safe_place_order(
|
||||
await _release_order_lock(stock_code, order_type)
|
||||
|
||||
|
||||
# ── [멀티테넌트 1단계 §5.8] 사용자 스코프 주문 락/래퍼 ────────────────────────
|
||||
# 기존 safe_place_order/_acquire_order_lock/_auto_cancel_if_pending는 시그니처
|
||||
# 불변 유지. 사용자별 계좌(ctx.kis)로 주문하는 경로는 아래 _v2 함수를 사용한다.
|
||||
async def _acquire_order_lock_v2(user_id, stock_code: str, order_type: str) -> bool:
|
||||
"""Redis 분산락 획득 — 사용자 네임스페이스 키(`order_lock:{user_id}:{code}:{type}`).
|
||||
|
||||
구조는 `_acquire_order_lock`과 동일(SETNX+TTL 60초). legacy 컨텍스트(user_id가
|
||||
None 또는 legacy owner id)도 이 함수를 쓰면 키에 user_id가 섞이므로, legacy
|
||||
경로는 기존 `_acquire_order_lock`(user_id 없는 키)을 그대로 사용해야 한다.
|
||||
"""
|
||||
try:
|
||||
from app.core.redis import get_redis
|
||||
client = get_redis()
|
||||
if not client:
|
||||
return True
|
||||
key = f"order_lock:{user_id}:{stock_code}:{order_type}"
|
||||
result = await client.set(key, "1", nx=True, ex=_ORDER_LOCK_TTL)
|
||||
return result is True
|
||||
except Exception as e:
|
||||
logger.debug(f"[order_lock_v2] user_id={user_id} 획득 오류 (패스): {e}")
|
||||
return True
|
||||
|
||||
|
||||
async def _release_order_lock_v2(user_id, stock_code: str, order_type: str) -> None:
|
||||
"""Redis 분산락 해제 — `_acquire_order_lock_v2`와 짝을 이루는 사용자 네임스페이스 키."""
|
||||
try:
|
||||
from app.core.redis import delete_key
|
||||
await delete_key(f"order_lock:{user_id}:{stock_code}:{order_type}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def safe_place_order_v2(
|
||||
ctx, # UserContext (순환 임포트 회피 — 타입은 app.services.tenant.context.UserContext)
|
||||
stock_code: str,
|
||||
order_type: str,
|
||||
quantity: int,
|
||||
price: float | None = None,
|
||||
is_domestic: bool = True,
|
||||
exchange_code: str = "NASD",
|
||||
) -> dict:
|
||||
"""`safe_place_order`의 사용자 스코프 버전 (설계 문서 §5.8).
|
||||
|
||||
- `ctx.is_legacy`면 기존 전역 경로(`_acquire_order_lock`/`_release_order_lock`,
|
||||
`_get_order_semaphore(stock_code)`)를 그대로 사용 — swing/daytrading exit_service
|
||||
등 기존 스케줄러가 잡는 락과 같은 키여야 같은 계좌·종목 동시 매도가 직렬화된다.
|
||||
사용자 컨텍스트(`is_legacy=False`)만 사용자 네임스페이스 키(`{user_id}:{code}`)를 쓴다.
|
||||
- 세마포어: `_get_order_semaphore(f"{ctx.user_id}:{stock_code}")` — 기존 함수 재사용,
|
||||
키 문자열만 사용자 네임스페이스로 바꿈(시그니처 불변).
|
||||
- Redis 락: `_acquire_order_lock_v2`/`_release_order_lock_v2` (legacy는 v1 그대로).
|
||||
- 주문 실행: `ctx.kis.place_order(...)`/`ctx.kis.place_overseas_order(...)`.
|
||||
- 반환 형식은 `safe_place_order`와 동일.
|
||||
"""
|
||||
if ctx.is_legacy:
|
||||
semaphore_key = stock_code
|
||||
lock_acquired = await _acquire_order_lock(stock_code, order_type)
|
||||
else:
|
||||
semaphore_key = f"{ctx.user_id}:{stock_code}"
|
||||
lock_acquired = await _acquire_order_lock_v2(ctx.user_id, stock_code, order_type)
|
||||
|
||||
if not lock_acquired:
|
||||
logger.warning(f"[SAFE_ORDER_V2] user_id={ctx.user_id} {stock_code} {order_type} — 분산락 획득 실패 (이미 처리 중)")
|
||||
return {"rt_cd": "-1", "msg1": "order_lock_busy"}
|
||||
|
||||
try:
|
||||
async with _get_order_semaphore(semaphore_key):
|
||||
if is_domestic:
|
||||
result = await ctx.kis.place_order(
|
||||
stock_code=stock_code,
|
||||
order_type=order_type,
|
||||
quantity=quantity,
|
||||
price=price,
|
||||
)
|
||||
else:
|
||||
result = await ctx.kis.place_overseas_order(
|
||||
symbol=stock_code,
|
||||
order_type=order_type,
|
||||
quantity=quantity,
|
||||
price=price or 0,
|
||||
exchange_code=exchange_code,
|
||||
)
|
||||
return result or {"rt_cd": "-1", "msg1": "no_response"}
|
||||
except Exception as e:
|
||||
logger.error(f"[SAFE_ORDER_V2] user_id={ctx.user_id} {stock_code} {order_type} 실패: {e}")
|
||||
return {"rt_cd": "-1", "msg1": str(e)}
|
||||
finally:
|
||||
if ctx.is_legacy:
|
||||
await _release_order_lock(stock_code, order_type)
|
||||
else:
|
||||
await _release_order_lock_v2(ctx.user_id, stock_code, order_type)
|
||||
|
||||
|
||||
async def _auto_cancel_if_pending_v2(ctx, order_id: str, stock_code: str, quantity: int, delay: int = 60) -> None:
|
||||
"""`_auto_cancel_if_pending`의 사용자 스코프 버전 — `ctx.kis.cancel_order`를 사용."""
|
||||
await asyncio.sleep(delay)
|
||||
try:
|
||||
result = await ctx.kis.cancel_order(order_id, quantity)
|
||||
if result.get("rt_cd") == "0":
|
||||
logger.info(f"[AUTO_CANCEL_V2] user_id={ctx.user_id} 미체결 자동 취소 완료: {stock_code} order_id={order_id}")
|
||||
else:
|
||||
logger.debug(f"[AUTO_CANCEL_V2] user_id={ctx.user_id} {stock_code} — {result.get('msg1')} (이미 체결됐을 수 있음)")
|
||||
except Exception as e:
|
||||
logger.debug(f"[AUTO_CANCEL_V2] user_id={ctx.user_id} {stock_code} — {type(e).__name__}: {e}")
|
||||
|
||||
|
||||
# ── 미체결 주문 1분 후 자동 취소 ─────────────────────────────────────────────
|
||||
async def _auto_cancel_if_pending(order_id: str, stock_code: str, quantity: int, delay: int = 60) -> None:
|
||||
"""delay초 후에도 PENDING 상태면 자동 취소.
|
||||
|
||||
3
backend/pytest.ini
Normal file
3
backend/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
markers =
|
||||
integration: 컨테이너 Postgres(+fakeredis/mock KIS)가 필요한 통합 테스트 — 라이브 DB/Redis/KIS에 접근하지 않음
|
||||
@@ -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
|
||||
|
||||
0
backend/tests/integration/__init__.py
Normal file
0
backend/tests/integration/__init__.py
Normal file
91
backend/tests/integration/conftest.py
Normal file
91
backend/tests/integration/conftest.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""통합 테스트(D그룹) 공용 fixture — 격리된 임시 DB 전용.
|
||||
|
||||
H4: `test_multi_tenant.py`와 `test_kis_credentials.py`가 각자 정의하던 이벤트 루프/
|
||||
lifespan/스키마 생성/DB 가드/HTTP 클라이언트 fixture를 이 파일로 통합한다. 두 파일
|
||||
모두 이 conftest를 통해 동일한 가드를 거치므로, `tests/integration` 디렉터리를
|
||||
통째로 실행해도(예: `pytest tests/integration -m integration`) users 테이블 등
|
||||
스키마가 없어 실패하는 일이 없다.
|
||||
|
||||
가드: `settings.DATABASE_URL`의 DB 이름이 `_test`로 끝나지 않으면 즉시
|
||||
`pytest.exit()` — 라이브 DB(예: 포트 15433의 `autostock_db`)를 실수로 겨냥한
|
||||
실행을 세션 시작 단계에서 차단한다. `os.environ["DATABASE_URL"]`만 보던 이전 가드는
|
||||
env 미설정 시 `settings.DATABASE_URL` 기본값(라이브 가능성)으로 조용히 새는 결함이
|
||||
있었다(구 검수 H4).
|
||||
|
||||
실행 예시 (임시 Postgres 컨테이너 안에서):
|
||||
docker run --rm --network host \
|
||||
-v $(pwd)/backend:/app -w /app \
|
||||
-e DATABASE_URL=postgresql://autostock:test@127.0.0.1:15499/autostock_test \
|
||||
-e SECRET_KEY=test-secret-key-for-integration-tests-32chars \
|
||||
-e CREDENTIALS_ENCRYPTION_KEY=test-cred-key-for-integration-tests-32c \
|
||||
-e BOOTSTRAP_ADMIN_PASSWORD=bootstrap-pw \
|
||||
auto-stock-backend:latest \
|
||||
bash -lc "pip install -q fakeredis pytest-asyncio httpx && python -m pytest tests/integration -m integration -v"
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport
|
||||
|
||||
from app.core.config import settings # noqa: E402
|
||||
|
||||
# ── 임시 DB 강제 확인 — DB 이름이 `_test`로 끝나지 않으면 세션 시작 자체를 중단 ──
|
||||
_db_url = settings.DATABASE_URL or ""
|
||||
_db_name = _db_url.rsplit("/", 1)[-1].split("?", 1)[0]
|
||||
if not _db_name.endswith("_test"):
|
||||
pytest.exit(
|
||||
f"settings.DATABASE_URL의 DB 이름이 '_test'로 끝나지 않습니다(현재: {_db_name!r}). "
|
||||
"통합 테스트는 반드시 임시 DB(예: autostock_test)를 대상으로 실행해야 합니다.",
|
||||
returncode=1,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""세션 전체가 동일 이벤트 루프를 공유하도록 강제 (asyncpg 풀 재사용).
|
||||
|
||||
기본(함수 스코프) 이벤트 루프를 쓰면 테스트마다 새 루프가 생성되는데, 앱 임포트 시
|
||||
한 번 만들어진 asyncpg 커넥션 풀(`AsyncSessionLocal`)의 유휴 커넥션이 이전 루프에
|
||||
묶여 있어 다음 테스트에서 "Event loop is closed" 오류가 난다.
|
||||
"""
|
||||
loop = asyncio.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _noop_lifespan(app):
|
||||
"""실거래 스케줄러/db_writer_worker/부트스트랩 자동 기동 방지 — 필요한 것만 테스트가 직접 기동."""
|
||||
yield
|
||||
|
||||
|
||||
from app.core.database import Base, engine # noqa: E402
|
||||
from app.main import app as fastapi_app # noqa: E402
|
||||
|
||||
fastapi_app.router.lifespan_context = _noop_lifespan
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session", autouse=True)
|
||||
async def _create_schema():
|
||||
"""임시 DB이므로 alembic 대신 `Base.metadata.create_all`로 스키마를 통째로 생성."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client():
|
||||
transport = ASGITransport(app=fastapi_app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
def _auth(token: str) -> dict:
|
||||
return {"X-API-Key": token}
|
||||
202
backend/tests/integration/test_kis_credentials.py
Normal file
202
backend/tests/integration/test_kis_credentials.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""KIS 자격증명 API 통합 테스트 (`kis_credentials.py`, `auth.py` 이관 API, D그룹 격리 환경).
|
||||
|
||||
- 격리 원칙: `test_multi_tenant.py`와 동일하게 **임시 Postgres 컨테이너**(DATABASE_URL이
|
||||
`_test`로 끝나야 함, `conftest.py`가 세션 시작 시 가드)에서만 실행한다. 라이브
|
||||
`autostock-postgres`/`autostock-backend`에는 어떤 쓰기 경로도 호출하지 않는다(구 검수 H4:
|
||||
이전 버전은 운영 DB를 직접 대상으로 했었음 — 수정됨).
|
||||
- 이벤트 루프/lifespan/스키마 생성/HTTP 클라이언트/`_auth` 헬퍼는 `conftest.py`(이 디렉터리
|
||||
공용, `test_multi_tenant.py`와 동일)가 제공한다.
|
||||
- 테스트가 생성한 사용자/자격증명은 각 fixture teardown에서 직접 삭제한다.
|
||||
- KIS 실계좌 호출(verify)은 `KoreaInvestmentAPI.for_user`를 monkeypatch 하여 네트워크를
|
||||
타지 않는다.
|
||||
|
||||
실행 예시 (임시 Postgres 컨테이너 안에서, `test_multi_tenant.py` docstring 참고):
|
||||
python -m pytest tests/integration/test_kis_credentials.py -m integration -v
|
||||
"""
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import delete
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
from app.core.database import AsyncSessionLocal # noqa: E402
|
||||
from app.core.security import _get_api_key, create_session_token # noqa: E402
|
||||
from app.models.models import User, UserKisCredential # noqa: E402
|
||||
from app.services.korea_investment_api import KoreaInvestmentAPI # noqa: E402
|
||||
from app.services.tenant.kis_pool import kis_client_pool # noqa: E402
|
||||
from app.api.endpoints import kis_credentials as kis_credentials_module # noqa: E402
|
||||
|
||||
from .conftest import _auth # noqa: E402
|
||||
|
||||
_SUFFIX = str(int(time.time()))
|
||||
|
||||
|
||||
class _FakeKis:
|
||||
"""`KoreaInvestmentAPI.for_user`가 반환할 가짜 인스턴스 (verify 단위 테스트용)."""
|
||||
|
||||
def __init__(self, rt_cd="0", raise_exc=None):
|
||||
self._rt_cd = rt_cd
|
||||
self._raise_exc = raise_exc
|
||||
self.closed = False
|
||||
|
||||
async def get_balance(self):
|
||||
if self._raise_exc:
|
||||
raise self._raise_exc
|
||||
return {
|
||||
"rt_cd": self._rt_cd,
|
||||
"msg1": "정상처리" if self._rt_cd == "0" else "계좌 조회 실패",
|
||||
"output2": [{"dnca_tot_amt": "1000000", "tot_evlu_amt": "5000000", "scts_evlu_amt": "4000000"}],
|
||||
}
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def two_users():
|
||||
"""테스트용 사용자 2명 생성 + 세션 토큰 발급. 종료 시 자격증명/사용자 완전 삭제."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
u1 = User(username=f"it_kis_a_{_SUFFIX}", password_hash="x", is_active=True, is_admin=False)
|
||||
u2 = User(username=f"it_kis_b_{_SUFFIX}", password_hash="x", is_active=True, is_admin=False)
|
||||
db.add_all([u1, u2])
|
||||
await db.commit()
|
||||
await db.refresh(u1)
|
||||
await db.refresh(u2)
|
||||
|
||||
token1, _ = create_session_token(u1.username)
|
||||
token2, _ = create_session_token(u2.username)
|
||||
ids = [u1.id, u2.id]
|
||||
|
||||
yield {"u1_id": u1.id, "u1_token": token1, "u2_id": u2.id, "u2_token": token2}
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await db.execute(delete(UserKisCredential).where(UserKisCredential.user_id.in_(ids)))
|
||||
await db.execute(delete(User).where(User.id.in_(ids)))
|
||||
await db.commit()
|
||||
for uid in ids:
|
||||
await kis_client_pool.invalidate(uid)
|
||||
kis_credentials_module._last_verify_attempt.pop(ids[0], None)
|
||||
kis_credentials_module._last_verify_attempt.pop(ids[1], None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_masked_view_duplicate_patch_delete(client, two_users):
|
||||
u1_headers = _auth(two_users["u1_token"])
|
||||
u2_headers = _auth(two_users["u2_token"])
|
||||
|
||||
# 최초 조회 — 미등록 404
|
||||
r = await client.get("/api/v1/account/credentials", headers=u1_headers)
|
||||
assert r.status_code == 404
|
||||
|
||||
body = {
|
||||
"mode": "virtual",
|
||||
"app_key": f"testappkey{_SUFFIX}",
|
||||
"app_secret": "testsecret",
|
||||
"account_no": "1234567801",
|
||||
}
|
||||
r = await client.put("/api/v1/account/credentials", headers=u1_headers, json=body)
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert data["mode"] == "virtual"
|
||||
assert data["app_key_hint"] == body["app_key"][:6] + "…"
|
||||
assert data["account_masked"] == "******" + "1234567801"[:8][-2:]
|
||||
assert data["is_enabled"] is False
|
||||
assert data["auto_trade_enabled"] is False
|
||||
assert data["last_verified_at"] is None
|
||||
# 평문이 응답에 없어야 함
|
||||
assert body["app_secret"] not in r.text
|
||||
assert body["app_key"] not in r.text
|
||||
|
||||
# 마스킹 조회
|
||||
r = await client.get("/api/v1/account/credentials", headers=u1_headers)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["account_masked"] == data["account_masked"]
|
||||
|
||||
# 잘못된 계좌번호 형식 (하이픈 제거 후 10자리 아님) → 422
|
||||
bad_body = dict(body, account_no="12345")
|
||||
r = await client.put("/api/v1/account/credentials", headers=u1_headers, json=bad_body)
|
||||
assert r.status_code == 422
|
||||
|
||||
# 동일 앱키를 다른 사용자가 등록 → 409
|
||||
dup_body = dict(body, account_no="9876543201")
|
||||
r = await client.put("/api/v1/account/credentials", headers=u2_headers, json=dup_body)
|
||||
assert r.status_code == 409
|
||||
|
||||
# 검증 전 자동매매 활성화 시도 → 400
|
||||
r = await client.patch("/api/v1/account/auto-trade", headers=u1_headers, json={"enabled": True})
|
||||
assert r.status_code == 400
|
||||
|
||||
# 자동매매 비활성화 요청은 검증 여부와 무관하게 허용
|
||||
r = await client.patch("/api/v1/account/auto-trade", headers=u1_headers, json={"enabled": False})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["auto_trade_enabled"] is False
|
||||
|
||||
# 삭제 → 204, 이후 조회 404
|
||||
r = await client.delete("/api/v1/account/credentials", headers=u1_headers)
|
||||
assert r.status_code == 204
|
||||
r = await client.get("/api/v1/account/credentials", headers=u1_headers)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_success_failure_and_cooldown(client, two_users, monkeypatch):
|
||||
headers = _auth(two_users["u1_token"])
|
||||
body = {
|
||||
"mode": "virtual",
|
||||
"app_key": f"verifykey{_SUFFIX}",
|
||||
"app_secret": "s",
|
||||
"account_no": "1122334401",
|
||||
}
|
||||
r = await client.put("/api/v1/account/credentials", headers=headers, json=body)
|
||||
assert r.status_code == 200
|
||||
|
||||
# 성공 케이스
|
||||
monkeypatch.setattr(KoreaInvestmentAPI, "for_user", classmethod(lambda cls, **kw: _FakeKis(rt_cd="0")))
|
||||
r = await client.post("/api/v1/account/credentials/verify", headers=headers)
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert data["ok"] is True
|
||||
assert data["balance_summary"]["total_assets"] == 5000000.0
|
||||
|
||||
# 60초 쿨다운 이내 재시도 → 429
|
||||
r = await client.post("/api/v1/account/credentials/verify", headers=headers)
|
||||
assert r.status_code == 429
|
||||
|
||||
# 쿨다운 우회 후 실패 케이스(rt_cd != 0) → 400
|
||||
kis_credentials_module._last_verify_attempt.pop(two_users["u1_id"], None)
|
||||
monkeypatch.setattr(KoreaInvestmentAPI, "for_user", classmethod(lambda cls, **kw: _FakeKis(rt_cd="1")))
|
||||
r = await client.post("/api/v1/account/credentials/verify", headers=headers)
|
||||
assert r.status_code == 400
|
||||
|
||||
# 검증 성공 후에는 자동매매 활성화 허용
|
||||
kis_credentials_module._last_verify_attempt.pop(two_users["u1_id"], None)
|
||||
monkeypatch.setattr(KoreaInvestmentAPI, "for_user", classmethod(lambda cls, **kw: _FakeKis(rt_cd="0")))
|
||||
r = await client.post("/api/v1/account/credentials/verify", headers=headers)
|
||||
assert r.status_code == 200
|
||||
r = await client.patch("/api/v1/account/auto-trade", headers=headers, json={"enabled": True})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["auto_trade_enabled"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_key_caller_maps_to_legacy_owner_or_400(client):
|
||||
"""API 키(X-API-Key=SECRET_KEY) 호출자는 legacy owner에 매핑되거나(admin 존재),
|
||||
admin이 전혀 없으면 400을 반환한다 — 어느 쪽이든 500이 아니어야 한다."""
|
||||
r = await client.get("/api/v1/account/credentials", headers=_auth(_get_api_key()))
|
||||
assert r.status_code in (200, 400, 404)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_tenant_status_requires_admin(client, two_users):
|
||||
# 일반 사용자는 관리자 API 접근 불가
|
||||
r = await client.get("/api/v1/auth/admin/tenant/status", headers=_auth(two_users["u1_token"]))
|
||||
assert r.status_code == 403
|
||||
|
||||
# API 키(SECRET_KEY)는 관리자로 간주되어 접근 가능
|
||||
r = await client.get("/api/v1/auth/admin/tenant/status", headers=_auth(_get_api_key()))
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert "multi_tenant_enabled" in data
|
||||
assert "null_user_id_counts" in data
|
||||
964
backend/tests/integration/test_multi_tenant.py
Normal file
964
backend/tests/integration/test_multi_tenant.py
Normal file
@@ -0,0 +1,964 @@
|
||||
"""멀티테넌트(사용자별 매매) 1단계 통합 테스트 (D그룹, 격리 환경).
|
||||
|
||||
`docs/multi_tenant_design.md` §7 "테스트 전략" (a)~(h) 대응.
|
||||
|
||||
- 격리 원칙: 반드시 **임시 Postgres 컨테이너**(DATABASE_URL을 그 컨테이너로 지정)에서
|
||||
실행한다. 라이브 `autostock-postgres`/`autostock-backend`/`autostock-redis`에는
|
||||
어떤 쓰기·주문 경로도 호출하지 않는다. Redis는 `fakeredis`로 대체하고,
|
||||
`KoreaInvestmentAPI`의 주문/잔고/`for_user` 계열은 전부 monkeypatch 로 가짜 구현을
|
||||
주입한다(가드: `test_00_mocks_applied_guard`).
|
||||
- 이벤트 루프/lifespan/스키마 생성/HTTP 클라이언트/DB 가드는 `conftest.py`(H4, 이 디렉터리
|
||||
공용)가 제공한다 — `settings.DATABASE_URL`이 `_test`로 끝나지 않으면 세션 시작 시
|
||||
`pytest.exit()`.
|
||||
- 실행 예시 (임시 컨테이너 안에서):
|
||||
docker run --rm --network host \
|
||||
-v $(pwd)/backend:/app -w /app \
|
||||
-e DATABASE_URL=postgresql://autostock:test@127.0.0.1:15499/autostock_test \
|
||||
-e SECRET_KEY=test-secret-key-for-integration-tests-32chars \
|
||||
-e CREDENTIALS_ENCRYPTION_KEY=test-cred-key-for-integration-tests-32c \
|
||||
-e BOOTSTRAP_ADMIN_PASSWORD=bootstrap-pw \
|
||||
auto-stock-backend:latest \
|
||||
bash -lc "pip install -q fakeredis pytest-asyncio httpx && python -m pytest tests/integration/test_multi_tenant.py -m integration -v"
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import delete, text
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
from app.core.database import AsyncSessionLocal # noqa: E402
|
||||
from app.core.security import _get_api_key, create_session_token # noqa: E402
|
||||
from app.models.models import ( # noqa: E402
|
||||
ActiveTrade,
|
||||
Portfolio,
|
||||
StockBanV2,
|
||||
TradeHistory,
|
||||
User,
|
||||
UserKisCredential,
|
||||
)
|
||||
from app.services import db_worker as db_worker_module # noqa: E402
|
||||
from app.services.db_worker import ( # noqa: E402
|
||||
db_writer_worker,
|
||||
enqueue_stock_ban_upsert_v2,
|
||||
)
|
||||
from app.services.korea_investment_api import KoreaInvestmentAPI # noqa: E402
|
||||
from app.services.loss_tracker import ConsecutiveLossTracker # noqa: E402
|
||||
from app.services.tenant import bootstrap as tenant_bootstrap # noqa: E402
|
||||
from app.services.tenant.kis_pool import kis_client_pool # noqa: E402
|
||||
from app.services.tenant.registry import user_context_registry # noqa: E402
|
||||
from app.services.tenant.context import UserContext # noqa: E402
|
||||
from app.services import trade_executor as trade_executor_module # noqa: E402
|
||||
from app.core import redis as redis_module # noqa: E402
|
||||
from app.api.endpoints import kis_credentials as kis_credentials_module # noqa: E402
|
||||
|
||||
from .conftest import _auth # noqa: E402
|
||||
|
||||
_SUFFIX = str(int(time.time()))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 가드 — mock이 실제로 적용됐는지 매 테스트 전에 확인 (§지시사항 필수 가드)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
class _FakeKis:
|
||||
"""`KoreaInvestmentAPI` 대체 — 네트워크 호출을 절대 하지 않는다."""
|
||||
|
||||
def __init__(self, rt_cd="0", balance=None, order_id="FAKE0001"):
|
||||
self._rt_cd = rt_cd
|
||||
self._balance = balance or {
|
||||
"rt_cd": "0",
|
||||
"output1": [],
|
||||
"output2": [{"dnca_tot_amt": "1000000", "tot_evlu_amt": "5000000", "scts_evlu_amt": "4000000"}],
|
||||
}
|
||||
self._order_id = order_id
|
||||
self.place_order_calls = []
|
||||
self.closed = False
|
||||
|
||||
async def get_balance(self):
|
||||
return self._balance
|
||||
|
||||
async def get_overseas_balance(self):
|
||||
return {"rt_cd": "0", "output1": [], "output2": [{}]}
|
||||
|
||||
async def get_current_price(self, stock_code):
|
||||
return {"output": {"stck_prpr": "10000", "hts_kor_isnm": f"종목{stock_code}"}}
|
||||
|
||||
async def place_order(self, stock_code, order_type, quantity, price=None):
|
||||
self.place_order_calls.append(
|
||||
{"stock_code": stock_code, "order_type": order_type, "quantity": quantity, "price": price}
|
||||
)
|
||||
return {"rt_cd": self._rt_cd, "msg1": "정상처리", "output": {"ODNO": self._order_id}}
|
||||
|
||||
async def cancel_order(self, order_id, quantity):
|
||||
return {"rt_cd": "0", "msg1": "정상처리"}
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _FakeMddAlwaysOpen:
|
||||
async def can_open_new_position(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class _FakeLossTrackerNeverBanned:
|
||||
async def is_banned_safe(self, stock_code: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _guard_no_real_kis(monkeypatch):
|
||||
"""모든 테스트에서 실제 KIS 호출 경로를 차단 — 클래스 메서드 자체를 막아 우회 불가하게 함."""
|
||||
|
||||
async def _blocked(*args, **kwargs):
|
||||
raise AssertionError("실제 KIS API 호출이 시도되었습니다 — 테스트에서는 반드시 mock 되어야 합니다")
|
||||
|
||||
monkeypatch.setattr(KoreaInvestmentAPI, "place_order", _blocked, raising=False)
|
||||
monkeypatch.setattr(KoreaInvestmentAPI, "place_overseas_order", _blocked, raising=False)
|
||||
yield
|
||||
|
||||
|
||||
def test_00_mocks_applied_guard(monkeypatch):
|
||||
"""가드: `safe_place_order_v2`가 실제로 `ctx.kis`(fake)를 호출하고, 전역
|
||||
`KoreaInvestmentAPI.place_order`는 호출되지 않는지 사전 확인."""
|
||||
fake = _FakeKis()
|
||||
assert asyncio.run(fake.place_order("005930", "BUY", 1)) is not None
|
||||
# 전역 클래스 메서드는 fixture에서 차단됨 — 직접 호출하면 AssertionError
|
||||
inst = KoreaInvestmentAPI.__new__(KoreaInvestmentAPI)
|
||||
with pytest.raises(AssertionError):
|
||||
asyncio.run(KoreaInvestmentAPI.place_order(inst, "005930", "BUY", 1))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 공용 fixture
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@pytest_asyncio.fixture
|
||||
async def two_users():
|
||||
"""테스트용 사용자 2명(A=admin/legacy owner, B=일반) 생성 + 세션 토큰. 종료 시 완전 삭제."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
u_a = User(username=f"mt_a_{_SUFFIX}", password_hash="x", is_active=True, is_admin=True)
|
||||
u_b = User(username=f"mt_b_{_SUFFIX}", password_hash="x", is_active=True, is_admin=False)
|
||||
db.add_all([u_a, u_b])
|
||||
await db.commit()
|
||||
await db.refresh(u_a)
|
||||
await db.refresh(u_b)
|
||||
|
||||
token_a, _ = create_session_token(u_a.username)
|
||||
token_b, _ = create_session_token(u_b.username)
|
||||
ids = [u_a.id, u_b.id]
|
||||
|
||||
yield {"a_id": u_a.id, "a_token": token_a, "a_user": u_a.username, "b_id": u_b.id, "b_token": token_b}
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await db.execute(delete(StockBanV2).where(StockBanV2.user_id.in_(ids)))
|
||||
await db.execute(delete(ActiveTrade).where(ActiveTrade.user_id.in_(ids)))
|
||||
await db.execute(delete(TradeHistory).where(TradeHistory.user_id.in_(ids)))
|
||||
await db.execute(delete(Portfolio).where(Portfolio.user_id.in_(ids)))
|
||||
await db.execute(delete(UserKisCredential).where(UserKisCredential.user_id.in_(ids)))
|
||||
await db.execute(delete(User).where(User.id.in_(ids)))
|
||||
await db.commit()
|
||||
for uid in ids:
|
||||
await kis_client_pool.invalidate(uid)
|
||||
await user_context_registry.invalidate(uid)
|
||||
kis_credentials_module._last_verify_attempt.pop(ids[0], None)
|
||||
kis_credentials_module._last_verify_attempt.pop(ids[1], None)
|
||||
|
||||
|
||||
async def _run_worker_and_wait(task: dict, check, timeout: float = 5.0):
|
||||
"""db_writer_worker를 짧게 기동해 task 1건을 처리시키고, `check()`가 True가
|
||||
될 때까지 폴링한 뒤 워커를 취소한다.
|
||||
|
||||
db_writer_worker는 무한 루프 + task_done() 미호출 구조라 queue.join()으로
|
||||
동기화할 수 없으므로, 결과가 DB에 반영됐는지 폴링으로 확인한다.
|
||||
"""
|
||||
queue = db_worker_module.get_db_task_queue()
|
||||
worker = asyncio.create_task(db_writer_worker())
|
||||
try:
|
||||
await queue.put(task)
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if await check():
|
||||
return
|
||||
await asyncio.sleep(0.05)
|
||||
raise AssertionError(f"db_worker task {task.get('type')} 결과를 {timeout}s 내에 확인하지 못함")
|
||||
finally:
|
||||
worker.cancel()
|
||||
try:
|
||||
await worker
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# (a) 두 사용자 register/login → PUT /account/credentials → 마스킹 조회 → 동일 앱키 409
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_register_login_credentials_duplicate_appkey(client, monkeypatch):
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "SIGNUP_ENABLED", True)
|
||||
monkeypatch.setattr(settings, "SIGNUP_AUTO_APPROVE", True)
|
||||
|
||||
uname1 = f"mt_reg_a_{_SUFFIX}"
|
||||
uname2 = f"mt_reg_b_{_SUFFIX}"
|
||||
try:
|
||||
r = await client.post("/api/v1/auth/register", json={"username": uname1, "password": "Passw0rd!123"})
|
||||
assert r.status_code == 201, r.text
|
||||
r = await client.post("/api/v1/auth/register", json={"username": uname2, "password": "Passw0rd!123"})
|
||||
assert r.status_code == 201, r.text
|
||||
|
||||
r = await client.post("/api/v1/auth/login", json={"username": uname1, "password": "Passw0rd!123"})
|
||||
assert r.status_code == 200, r.text
|
||||
token1 = r.json()["token"]
|
||||
r = await client.post("/api/v1/auth/login", json={"username": uname2, "password": "Passw0rd!123"})
|
||||
assert r.status_code == 200, r.text
|
||||
token2 = r.json()["token"]
|
||||
|
||||
h1, h2 = _auth(token1), _auth(token2)
|
||||
body = {"mode": "virtual", "app_key": f"regtestkey{_SUFFIX}", "app_secret": f"regsecret{_SUFFIX}", "account_no": "1111111101"}
|
||||
r = await client.put("/api/v1/account/credentials", headers=h1, json=body)
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert data["app_key_hint"] == body["app_key"][:6] + "…"
|
||||
assert body["app_key"] not in r.text and body["app_secret"] not in r.text
|
||||
|
||||
r = await client.get("/api/v1/account/credentials", headers=h1)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["app_key_hint"] == data["app_key_hint"]
|
||||
|
||||
dup = dict(body, account_no="2222222201")
|
||||
r = await client.put("/api/v1/account/credentials", headers=h2, json=dup)
|
||||
assert r.status_code == 409
|
||||
finally:
|
||||
async with AsyncSessionLocal() as db:
|
||||
uids_res = await db.execute(select_users_by_username(uname1, uname2))
|
||||
uids = [row[0] for row in uids_res]
|
||||
await db.execute(delete(UserKisCredential).where(UserKisCredential.user_id.in_(uids)))
|
||||
await db.execute(delete(User).where(User.id.in_(uids)))
|
||||
await db.commit()
|
||||
for uid in uids:
|
||||
await kis_client_pool.invalidate(uid)
|
||||
|
||||
|
||||
def select_users_by_username(*usernames):
|
||||
from sqlalchemy import select as _select
|
||||
return _select(User.id).where(User.username.in_(usernames))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# (b) upsert_portfolio — user_id A/B로 동일 종목 2행 공존 (0012 이전 알려진 제약 → xfail)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason=(
|
||||
"설계 §3.2/§7: portfolio.stock_code 는 0012 마이그레이션 전까지 전역 unique 유지 — "
|
||||
"서로 다른 user_id로 동일 stock_code를 upsert하면 두 번째 INSERT가 UniqueViolation으로 실패한다."
|
||||
),
|
||||
)
|
||||
async def test_b_upsert_portfolio_same_stock_two_users_conflict(two_users):
|
||||
stock_code = f"9{_SUFFIX[-5:]}" # 6자리 임시 코드
|
||||
|
||||
async def _row_exists(user_id: int) -> bool:
|
||||
async with AsyncSessionLocal() as db:
|
||||
from sqlalchemy import select as _select
|
||||
res = await db.execute(
|
||||
_select(Portfolio).where(Portfolio.user_id == user_id, Portfolio.stock_code == stock_code)
|
||||
)
|
||||
return res.scalar_one_or_none() is not None
|
||||
|
||||
await _run_worker_and_wait(
|
||||
{
|
||||
"type": "upsert_portfolio",
|
||||
"data": {
|
||||
"user_id": two_users["a_id"], "stock_code": stock_code, "stock_name": "테스트",
|
||||
"quantity": 10, "average_price": 1000, "current_price": 1000,
|
||||
"market": "KRX", "strategy_type": "manual",
|
||||
},
|
||||
},
|
||||
lambda: _row_exists(two_users["a_id"]),
|
||||
)
|
||||
# 서로 다른 user_id로 동일 stock_code upsert 시도 — unique(stock_code) 위반으로 두 번째 행이 생기지 않아야
|
||||
# "성공"(xfail 대상)하지만, 실제로는 IntegrityError 로 조용히 실패(예외를 삼키고 재시도 로직을 태움)한다.
|
||||
await _run_worker_and_wait(
|
||||
{
|
||||
"type": "upsert_portfolio",
|
||||
"data": {
|
||||
"user_id": two_users["b_id"], "stock_code": stock_code, "stock_name": "테스트",
|
||||
"quantity": 5, "average_price": 2000, "current_price": 2000,
|
||||
"market": "KRX", "strategy_type": "manual",
|
||||
},
|
||||
},
|
||||
lambda: _row_exists(two_users["b_id"]),
|
||||
timeout=2.0,
|
||||
)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
from sqlalchemy import select as _select
|
||||
res = await db.execute(_select(Portfolio).where(Portfolio.stock_code == stock_code))
|
||||
rows = res.scalars().all()
|
||||
assert len(rows) == 2, "두 사용자가 동일 종목을 공존 보유해야 한다 (0012 이전에는 실패가 알려진 제약)"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# (c) upsert_stock_ban_v2 — 사용자별 ban 분리 + load_from_db_v2 복원
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_c_stock_ban_v2_upsert_tz_aware_datetime_bug(two_users):
|
||||
"""실제 loss_tracker 경로(tz-aware ban_until)로 upsert_stock_ban_v2를 태워도
|
||||
db_worker가 저장 직전 naive-UTC로 정규화해 DB write가 성공해야 한다
|
||||
(수정 전에는 asyncpg DataError로 DLQ에 빠졌던 결함)."""
|
||||
stock_code = "005930"
|
||||
ban_until_a = datetime.now(timezone.utc) + timedelta(days=42) # 실제 코드와 동일하게 tz-aware
|
||||
|
||||
async def _ban_exists(user_id: int) -> bool:
|
||||
async with AsyncSessionLocal() as db:
|
||||
from sqlalchemy import select as _select
|
||||
res = await db.execute(
|
||||
_select(StockBanV2).where(StockBanV2.user_id == user_id, StockBanV2.stock_code == stock_code)
|
||||
)
|
||||
return res.scalar_one_or_none() is not None
|
||||
|
||||
await enqueue_stock_ban_upsert_v2(two_users["a_id"], stock_code, 3, ban_until_a)
|
||||
worker = asyncio.create_task(db_writer_worker())
|
||||
try:
|
||||
deadline = time.monotonic() + 8 # 지수 백오프(1→2→4s) 3회 재시도를 기다림
|
||||
while time.monotonic() < deadline and not await _ban_exists(two_users["a_id"]):
|
||||
await asyncio.sleep(0.1)
|
||||
assert await _ban_exists(two_users["a_id"])
|
||||
finally:
|
||||
worker.cancel()
|
||||
try:
|
||||
await worker
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_c_stock_ban_v2_load_from_db_v2_per_user_isolation(two_users):
|
||||
"""읽기 경로(load_from_db_v2)만 검증 — 위 결함(tz-aware 쓰기)과 독립적으로,
|
||||
tz-naive 값을 직접 INSERT해 사용자별 격리가 올바른지 확인한다."""
|
||||
stock_code = "005930"
|
||||
ban_until_a = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(days=42)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
db.add(StockBanV2(user_id=two_users["a_id"], stock_code=stock_code, consecutive_losses=3, ban_until=ban_until_a))
|
||||
await db.commit()
|
||||
|
||||
tracker_a = ConsecutiveLossTracker(ban_count=3, ban_calendar_days=42)
|
||||
tracker_b = ConsecutiveLossTracker(ban_count=3, ban_calendar_days=42)
|
||||
async with AsyncSessionLocal() as db:
|
||||
await tracker_a.load_from_db_v2(db, two_users["a_id"])
|
||||
await tracker_b.load_from_db_v2(db, two_users["b_id"])
|
||||
|
||||
assert await tracker_a.is_banned_safe(stock_code) is True
|
||||
assert await tracker_b.is_banned_safe(stock_code) is False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# (c-legacy) save_to_db() → load_from_db() 라운드트립 — 레거시 stock_bans도
|
||||
# tz-aware ban_until을 안전하게 저장·복원해야 한다 (db_worker.py fix 회귀 방지)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_c_legacy_stock_ban_save_load_roundtrip_tz_aware():
|
||||
from app.models.models import StockBan
|
||||
|
||||
stock_code = f"8{_SUFFIX[-5:]}" # 6자리 임시 코드, stock_bans PK 충돌 회피
|
||||
|
||||
async def _ban_row(db):
|
||||
from sqlalchemy import select as _select
|
||||
res = await db.execute(_select(StockBan).where(StockBan.stock_code == stock_code))
|
||||
return res.scalar_one_or_none()
|
||||
|
||||
tracker = ConsecutiveLossTracker(ban_count=3, ban_calendar_days=42)
|
||||
tracker.record_trade_result(stock_code, is_loss=True)
|
||||
tracker.record_trade_result(stock_code, is_loss=True)
|
||||
tracker.record_trade_result(stock_code, is_loss=True) # 3연패 → tz-aware ban_until 생성
|
||||
|
||||
worker = asyncio.create_task(db_writer_worker())
|
||||
try:
|
||||
await tracker.save_to_db(stock_code=stock_code)
|
||||
deadline = time.monotonic() + 5.0
|
||||
row = None
|
||||
while time.monotonic() < deadline:
|
||||
async with AsyncSessionLocal() as db:
|
||||
row = await _ban_row(db)
|
||||
if row is not None:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
assert row is not None, "save_to_db()가 stock_bans에 tz-aware ban_until을 정규화해 저장해야 한다"
|
||||
assert row.ban_until.tzinfo is None, "stock_bans.ban_until은 naive-UTC로 저장돼야 한다"
|
||||
finally:
|
||||
worker.cancel()
|
||||
try:
|
||||
await worker
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
reloaded = ConsecutiveLossTracker(ban_count=3, ban_calendar_days=42)
|
||||
async with AsyncSessionLocal() as db:
|
||||
await reloaded.load_from_db(db)
|
||||
|
||||
assert reloaded.is_banned(stock_code) is True
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await db.execute(delete(StockBan).where(StockBan.stock_code == stock_code))
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# (d) safe_place_order_v2 락 키 — fakeredis로 user_id별 키가 다르게 생성되는지
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_d_order_lock_key_namespaced_by_user(monkeypatch, two_users):
|
||||
fakeredis = pytest.importorskip("fakeredis")
|
||||
fake_client = fakeredis.aioredis.FakeRedis(decode_responses=True)
|
||||
monkeypatch.setattr(redis_module, "_redis_client", fake_client)
|
||||
|
||||
ctx_a = UserContext(
|
||||
user_id=two_users["a_id"], username="a", mode="virtual",
|
||||
kis=_FakeKis(), mdd=None, loss_tracker=None, auto_trade_enabled=True, is_legacy=False,
|
||||
)
|
||||
ctx_b = UserContext(
|
||||
user_id=two_users["b_id"], username="b", mode="virtual",
|
||||
kis=_FakeKis(), mdd=None, loss_tracker=None, auto_trade_enabled=True, is_legacy=False,
|
||||
)
|
||||
|
||||
seen_keys = []
|
||||
orig_set = fake_client.set
|
||||
|
||||
async def _tracking_set(key, *a, **kw):
|
||||
seen_keys.append(key)
|
||||
return await orig_set(key, *a, **kw)
|
||||
|
||||
monkeypatch.setattr(fake_client, "set", _tracking_set)
|
||||
|
||||
r_a = await trade_executor_module.safe_place_order_v2(ctx_a, "005930", "BUY", 1)
|
||||
r_b = await trade_executor_module.safe_place_order_v2(ctx_b, "005930", "BUY", 1)
|
||||
|
||||
assert r_a["rt_cd"] == "0" and r_b["rt_cd"] == "0"
|
||||
assert ctx_a.kis.place_order_calls and ctx_b.kis.place_order_calls
|
||||
expected_a = f"order_lock:{two_users['a_id']}:005930:BUY"
|
||||
expected_b = f"order_lock:{two_users['b_id']}:005930:BUY"
|
||||
assert expected_a in seen_keys
|
||||
assert expected_b in seen_keys
|
||||
assert expected_a != expected_b
|
||||
|
||||
await fake_client.aclose()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# (e) MULTI_TENANT_ENABLED=False 에서 GET /portfolio/v2 == GET /portfolio (레거시 호환)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_e_legacy_parity_portfolio_v2_matches_portfolio(client, monkeypatch):
|
||||
from app.core.config import settings
|
||||
from app.api.endpoints import portfolio as portfolio_module
|
||||
from app.services import korea_investment_api as kis_module
|
||||
|
||||
monkeypatch.setattr(settings, "MULTI_TENANT_ENABLED", False)
|
||||
|
||||
# effective_user_id가 legacy owner id를 반환하려면 admin(=legacy owner)이 최소 1명 있어야 함
|
||||
# (설계상 admin 0명이면 400 — 이 테스트는 그 케이스가 아니라 "정상 legacy 경로"를 검증)
|
||||
async with AsyncSessionLocal() as db:
|
||||
legacy_admin = User(username=f"mt_legacy_owner_{_SUFFIX}", password_hash="x", is_active=True, is_admin=True)
|
||||
db.add(legacy_admin)
|
||||
await db.commit()
|
||||
await db.refresh(legacy_admin)
|
||||
legacy_admin_id = legacy_admin.id
|
||||
# 이전 테스트가 캐싱한 legacy owner_id(0/None)를 무효화 — invalidate_all()은
|
||||
# 사용자별 캐시만 지우고 legacy 캐시(TTL 60s)는 건드리지 않으므로 직접 리셋한다.
|
||||
user_context_registry._legacy_ctx = None
|
||||
user_context_registry._legacy_checked_at = 0.0
|
||||
|
||||
async def _fake_get_current_price(self, code):
|
||||
return {"output": {"stck_prpr": "55000", "hts_kor_isnm": "삼성전자우"}}
|
||||
|
||||
monkeypatch.setattr(KoreaInvestmentAPI, "get_current_price", _fake_get_current_price, raising=False)
|
||||
|
||||
stock_code = "005935"
|
||||
fake_balance = {
|
||||
"rt_cd": "0",
|
||||
"output1": [
|
||||
{
|
||||
"pdno": stock_code, "hldg_qty": "3", "pchs_avg_pric": "50000",
|
||||
"prpr": "55000", "evlu_pfls_amt": "15000", "evlu_pfls_rt": "10.0",
|
||||
"prdt_name": "삼성전자우",
|
||||
}
|
||||
],
|
||||
"output2": [{"dnca_tot_amt": "1000000", "tot_evlu_amt": "5000000"}],
|
||||
}
|
||||
|
||||
async def _fake_get_balance():
|
||||
return fake_balance
|
||||
|
||||
async def _fake_get_overseas_balance():
|
||||
return {"rt_cd": "0", "output1": [], "output2": [{}]}
|
||||
|
||||
monkeypatch.setattr(kis_module.korea_investment_api, "get_balance", _fake_get_balance)
|
||||
monkeypatch.setattr(kis_module.korea_investment_api, "get_overseas_balance", _fake_get_overseas_balance)
|
||||
|
||||
# 캐시를 강제로 비워 이번 호출에서 반드시 재동기화되게 함
|
||||
portfolio_module.portfolio_cache.holdings = []
|
||||
portfolio_module.portfolio_cache.last_synced = None
|
||||
portfolio_module.portfolio_cache.mark_changed()
|
||||
|
||||
try:
|
||||
r_legacy = await client.get("/api/v1/portfolio", headers=_auth(_get_api_key()))
|
||||
assert r_legacy.status_code == 200, r_legacy.text
|
||||
legacy_rows = sorted(r_legacy.json(), key=lambda h: h["stock_code"])
|
||||
|
||||
r_v2 = await client.get("/api/v1/portfolio/v2", headers=_auth(_get_api_key()))
|
||||
assert r_v2.status_code == 200, r_v2.text
|
||||
v2_rows = sorted(r_v2.json(), key=lambda h: h["stock_code"])
|
||||
|
||||
assert [h["stock_code"] for h in legacy_rows] == [h["stock_code"] for h in v2_rows]
|
||||
assert [h["quantity"] for h in legacy_rows] == [h["quantity"] for h in v2_rows]
|
||||
finally:
|
||||
async with AsyncSessionLocal() as db:
|
||||
await db.execute(delete(Portfolio).where(Portfolio.stock_code == stock_code))
|
||||
await db.execute(delete(User).where(User.id == legacy_admin_id))
|
||||
await db.commit()
|
||||
portfolio_module.portfolio_cache.holdings = []
|
||||
portfolio_module.portfolio_cache.mark_changed()
|
||||
await user_context_registry.invalidate_all()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# (f) GET /portfolio/v2 — 사용자별 격리 + 관리자 ?user_id= 열람 + 비관리자 403
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_f_portfolio_v2_isolation_and_admin_override(client, monkeypatch, two_users):
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "MULTI_TENANT_ENABLED", True)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
db.add(Portfolio(
|
||||
user_id=two_users["a_id"], stock_code="000660", stock_name="A종목",
|
||||
quantity=5, average_price=1000, current_price=1000, profit_loss=0, profit_loss_rate=0,
|
||||
market="KRX", strategy_type="manual",
|
||||
))
|
||||
db.add(Portfolio(
|
||||
user_id=two_users["b_id"], stock_code="035420", stock_name="B종목",
|
||||
quantity=7, average_price=2000, current_price=2000, profit_loss=0, profit_loss_rate=0,
|
||||
market="KRX", strategy_type="manual",
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
r = await client.get("/api/v1/portfolio/v2", headers=_auth(two_users["a_token"]))
|
||||
assert r.status_code == 200
|
||||
codes_a = {h["stock_code"] for h in r.json()}
|
||||
assert "000660" in codes_a
|
||||
assert "035420" not in codes_a # B의 행이 보이지 않음
|
||||
|
||||
r = await client.get("/api/v1/portfolio/v2", headers=_auth(two_users["b_token"]))
|
||||
codes_b = {h["stock_code"] for h in r.json()}
|
||||
assert "035420" in codes_b
|
||||
assert "000660" not in codes_b
|
||||
|
||||
# 관리자(A)가 ?user_id=B 로 조회 — 허용
|
||||
r = await client.get(
|
||||
f"/api/v1/portfolio/v2?user_id={two_users['b_id']}", headers=_auth(two_users["a_token"])
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert "035420" in {h["stock_code"] for h in r.json()}
|
||||
|
||||
# 비관리자(B)가 ?user_id=A 로 조회 — 403
|
||||
r = await client.get(
|
||||
f"/api/v1/portfolio/v2?user_id={two_users['a_id']}", headers=_auth(two_users["b_token"])
|
||||
)
|
||||
assert r.status_code == 403
|
||||
finally:
|
||||
async with AsyncSessionLocal() as db:
|
||||
await db.execute(delete(Portfolio).where(Portfolio.stock_code.in_(["000660", "035420"])))
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# (g) POST /portfolio/v2/sell — 타 사용자 종목 404, 본인 종목은 ctx.user_id로 주문·close 호출
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_g_sell_v2_cross_user_404_and_own_stock_uses_ctx(client, monkeypatch, two_users):
|
||||
from app.core.config import settings
|
||||
from app.services.tenant import registry as registry_module
|
||||
|
||||
monkeypatch.setattr(settings, "MULTI_TENANT_ENABLED", True)
|
||||
|
||||
fake_kis_a = _FakeKis()
|
||||
ctx_a = UserContext(
|
||||
user_id=two_users["a_id"], username="a", mode="virtual", kis=fake_kis_a,
|
||||
mdd=None, loss_tracker=None, auto_trade_enabled=True, is_legacy=False,
|
||||
)
|
||||
|
||||
async def _fake_resolve(current_user, db):
|
||||
return ctx_a
|
||||
|
||||
monkeypatch.setattr(registry_module.user_context_registry, "resolve", _fake_resolve)
|
||||
|
||||
close_calls = []
|
||||
|
||||
async def _fake_close(user_id, stock_code, strategy_name=None):
|
||||
close_calls.append((user_id, stock_code, strategy_name))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.api.endpoints.portfolio_v2.enqueue_close_active_trade_v2", _fake_close
|
||||
)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
db.add(Portfolio(
|
||||
user_id=two_users["a_id"], stock_code="005380", stock_name="A보유",
|
||||
quantity=10, average_price=1000, current_price=1000, profit_loss=0, profit_loss_rate=0,
|
||||
market="KRX", strategy_type="manual",
|
||||
))
|
||||
db.add(Portfolio(
|
||||
user_id=two_users["b_id"], stock_code="051910", stock_name="B보유",
|
||||
quantity=10, average_price=1000, current_price=1000, profit_loss=0, profit_loss_rate=0,
|
||||
market="KRX", strategy_type="manual",
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
# B 종목을 A가 매도 시도 → 404
|
||||
r = await client.post(
|
||||
"/api/v1/portfolio/v2/sell",
|
||||
headers=_auth(two_users["a_token"]),
|
||||
json={"stock_code": "051910", "quantity": 1},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
# A 종목 매도 → 성공, fake kis 호출 인자에 ctx.user_id 반영 확인
|
||||
r = await client.post(
|
||||
"/api/v1/portfolio/v2/sell",
|
||||
headers=_auth(two_users["a_token"]),
|
||||
json={"stock_code": "005380", "quantity": 10},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert fake_kis_a.place_order_calls, "safe_place_order_v2가 ctx.kis(fake)를 통해 주문해야 한다"
|
||||
call = fake_kis_a.place_order_calls[0]
|
||||
assert call["stock_code"] == "005380" and call["order_type"] == "SELL"
|
||||
assert close_calls and close_calls[0][0] == two_users["a_id"] and close_calls[0][1] == "005380"
|
||||
finally:
|
||||
async with AsyncSessionLocal() as db:
|
||||
await db.execute(delete(Portfolio).where(Portfolio.stock_code.in_(["005380", "051910"])))
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# (h) ensure_bootstrap_admin() — 빈 users, KIS env 이관, idempotent, NULL 백필
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_h_bootstrap_admin_seed_and_idempotent(monkeypatch):
|
||||
from app.core.config import settings
|
||||
|
||||
# 이 테스트만을 위한 격리 — users 테이블을 비워야 하므로 세션 스코프 오염 방지를 위해
|
||||
# 테스트 전용 사용자명으로 필터링된 범위만 조작 가능해야 하나, ensure_bootstrap_admin은
|
||||
# "users 테이블 전체가 비어있는지"를 보므로 실제로 테이블을 비운다 — 임시 DB 한정.
|
||||
async with AsyncSessionLocal() as db:
|
||||
existing = (await db.execute(select_all_users())).scalars().all()
|
||||
assert existing == [], (
|
||||
"이 테스트는 users 테이블이 완전히 비어 있어야 한다 — 임시 격리 DB 전용 테스트. "
|
||||
"다른 테스트와 병렬/순서 오염 시 실패할 수 있음 (세션 순차 실행 전제)."
|
||||
)
|
||||
|
||||
monkeypatch.setattr(settings, "BOOTSTRAP_ADMIN_PASSWORD", "bootstrap-pw-123")
|
||||
monkeypatch.setattr(settings, "BOOTSTRAP_ADMIN_USERNAME", f"bootadmin_{_SUFFIX}")
|
||||
monkeypatch.setattr(settings, "CREDENTIALS_ENCRYPTION_KEY", "x" * 40)
|
||||
monkeypatch.setattr(settings, "KOREA_INVESTMENT_APP_KEY", f"envappkey{_SUFFIX}")
|
||||
monkeypatch.setattr(settings, "KOREA_INVESTMENT_APP_SECRET", "envsecret")
|
||||
monkeypatch.setattr(settings, "KOREA_INVESTMENT_ACCOUNT_NUMBER", "1234567801")
|
||||
monkeypatch.setattr(settings, "KIS_MODE", "virtual")
|
||||
monkeypatch.setattr(settings, "AUTO_TRADE_ENABLED", False)
|
||||
|
||||
try:
|
||||
await tenant_bootstrap.ensure_bootstrap_admin()
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
from sqlalchemy import select as _select
|
||||
admin = (
|
||||
await db.execute(_select(User).where(User.username == f"bootadmin_{_SUFFIX}"))
|
||||
).scalar_one_or_none()
|
||||
assert admin is not None
|
||||
assert admin.is_admin is True and admin.is_active is True
|
||||
|
||||
cred = (
|
||||
await db.execute(_select(UserKisCredential).where(UserKisCredential.user_id == admin.id))
|
||||
).scalar_one_or_none()
|
||||
assert cred is not None
|
||||
from app.core.crypto import decrypt
|
||||
assert decrypt(cred.app_key_enc, cred.key_version) == f"envappkey{_SUFFIX}"
|
||||
assert decrypt(cred.account_no_enc, cred.key_version) == "1234567801"
|
||||
|
||||
# NULL user_id 백필 확인용 레거시 행 삽입 후 재실행
|
||||
async with AsyncSessionLocal() as db:
|
||||
db.add(Portfolio(
|
||||
user_id=None, stock_code="900001", stock_name="레거시", quantity=1,
|
||||
average_price=100, current_price=100, market="KRX", strategy_type="swing",
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
await tenant_bootstrap.ensure_bootstrap_admin() # 2번째 호출 — idempotent 여야 함
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
from sqlalchemy import select as _select, func as _func
|
||||
admin_count = (
|
||||
await db.execute(
|
||||
_select(_func.count()).select_from(User).where(User.username == f"bootadmin_{_SUFFIX}")
|
||||
)
|
||||
).scalar()
|
||||
assert admin_count == 1, "재호출해도 관리자 계정이 중복 생성되지 않아야 한다"
|
||||
|
||||
cred_count = (
|
||||
await db.execute(
|
||||
_select(_func.count()).select_from(UserKisCredential).where(UserKisCredential.user_id == admin.id)
|
||||
)
|
||||
).scalar()
|
||||
assert cred_count == 1, "재호출해도 KIS 자격증명이 중복 이관되지 않아야 한다"
|
||||
|
||||
legacy_row = (
|
||||
await db.execute(_select(Portfolio).where(Portfolio.stock_code == "900001"))
|
||||
).scalar_one_or_none()
|
||||
assert legacy_row is not None and legacy_row.user_id == admin.id, (
|
||||
"user_id IS NULL 이던 레거시 행이 owner로 백필되어야 한다"
|
||||
)
|
||||
finally:
|
||||
async with AsyncSessionLocal() as db:
|
||||
from sqlalchemy import select as _select
|
||||
admin = (
|
||||
await db.execute(_select(User).where(User.username == f"bootadmin_{_SUFFIX}"))
|
||||
).scalar_one_or_none()
|
||||
await db.execute(delete(Portfolio).where(Portfolio.stock_code == "900001"))
|
||||
if admin is not None:
|
||||
await db.execute(delete(UserKisCredential).where(UserKisCredential.user_id == admin.id))
|
||||
await db.execute(delete(User).where(User.id == admin.id))
|
||||
await db.commit()
|
||||
|
||||
|
||||
def select_all_users():
|
||||
from sqlalchemy import select as _select
|
||||
return _select(User)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# M1 회귀 방지 — CurrentUser.effective_user_id 와 UserContextRegistry.legacy().user_id 가
|
||||
# 관리자 미존재 상태에서 모두 None 으로 일치해야 한다(수정 전에는 0 vs None 불일치 결함이었음).
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_m1_effective_user_id_matches_legacy_owner_id_when_no_admin():
|
||||
"""관리자 0명 상태에서 `CurrentUser.effective_user_id`와
|
||||
`UserContextRegistry.legacy().user_id`가 모두 `None`으로 일치해야 한다(M1).
|
||||
|
||||
수정 전에는 후자가 `users.id=0`(존재하지 않는 FK 위반 값)을 반환해
|
||||
`enqueue_trade_history({"user_id": 0})` → FK 위반 → db_worker 재시도 후 폐기로
|
||||
이어졌다.
|
||||
"""
|
||||
from app.core.tenant_auth import CurrentUser
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
no_admin = (
|
||||
await db.execute(select_all_users())
|
||||
).scalars().all()
|
||||
# 관리자 없는 상태를 흉내내기 위해 admin 여부만 확인 (실제 삭제는 하지 않음 — 다른 테스트와 공유 DB)
|
||||
has_admin = any(u.is_admin for u in no_admin)
|
||||
|
||||
current_user = CurrentUser(id=None, username="api-key", is_admin=True, is_api_key=True, _legacy_owner_id=None)
|
||||
assert current_user.effective_user_id is None
|
||||
|
||||
if not has_admin:
|
||||
# legacy 캐시가 이전 테스트에서 owner_id로 확정(캐시)돼 있을 수 있으므로 리셋
|
||||
user_context_registry._legacy_ctx = None
|
||||
user_context_registry._legacy_checked_at = 0.0
|
||||
user_context_registry._legacy_owner_confirmed = False
|
||||
legacy_ctx = await user_context_registry.legacy(None)
|
||||
assert legacy_ctx.user_id is None
|
||||
assert current_user.effective_user_id == legacy_ctx.user_id
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# H2 — 비활성(회수) 세션 사용자는 legacy owner 로 폴백하지 않고 401
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_h2_inactive_session_user_returns_401_not_legacy_fallback(client):
|
||||
async with AsyncSessionLocal() as db:
|
||||
u = User(username=f"mt_revoked_{_SUFFIX}", password_hash="x", is_active=False, is_admin=False)
|
||||
db.add(u)
|
||||
await db.commit()
|
||||
await db.refresh(u)
|
||||
token, _ = create_session_token(u.username)
|
||||
|
||||
try:
|
||||
r = await client.get("/api/v1/account/credentials", headers=_auth(token))
|
||||
assert r.status_code == 401, r.text
|
||||
r = await client.get("/api/v1/portfolio/v2", headers=_auth(token))
|
||||
assert r.status_code == 401, r.text
|
||||
finally:
|
||||
async with AsyncSessionLocal() as db:
|
||||
await db.execute(delete(User).where(User.id == u.id))
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# H3 — 자격증명 PUT/DELETE/PATCH 후 registry 캐시가 무효화되어야 함
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_h3_credential_endpoints_invalidate_registry_cache(client, two_users):
|
||||
user_id = two_users["a_id"]
|
||||
headers = _auth(two_users["a_token"])
|
||||
|
||||
body = {"mode": "virtual", "app_key": f"h3key{_SUFFIX}", "app_secret": "s", "account_no": "1234567801"}
|
||||
r = await client.put("/api/v1/account/credentials", headers=headers, json=body)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
dummy_ctx = UserContext(
|
||||
user_id=user_id, username="stale", mode="virtual", kis=_FakeKis(),
|
||||
mdd=None, loss_tracker=None, auto_trade_enabled=True, is_legacy=False,
|
||||
)
|
||||
|
||||
# PUT은 이미 위에서 한 번 invalidate를 호출했으므로, 이후 더미를 주입해 재확인
|
||||
user_context_registry._contexts[user_id] = dummy_ctx
|
||||
assert user_context_registry._contexts.get(user_id) is dummy_ctx
|
||||
|
||||
r = await client.patch("/api/v1/account/auto-trade", headers=headers, json={"enabled": False})
|
||||
assert r.status_code == 200, r.text
|
||||
assert user_id not in user_context_registry._contexts, (
|
||||
"PATCH /auto-trade 이후 캐시된 UserContext가 무효화되어야 한다(H3)"
|
||||
)
|
||||
|
||||
user_context_registry._contexts[user_id] = dummy_ctx
|
||||
r = await client.delete("/api/v1/account/credentials", headers=headers)
|
||||
assert r.status_code == 204, r.text
|
||||
assert user_id not in user_context_registry._contexts, (
|
||||
"DELETE /credentials 이후 캐시된 UserContext가 무효화되어야 한다(H3)"
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# M1 — owner 없는 API 키(legacy ctx user_id=None) 매수 시 db_worker payload에 user_id 키가
|
||||
# 없어야 한다(0을 넣어 FK 위반을 내던 구 결함 방지, 기존 NULL 행과 정합).
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_m1_legacy_ctx_no_owner_buy_payload_omits_user_id(client, monkeypatch):
|
||||
from app.core.config import settings
|
||||
from app.services.tenant import registry as registry_module
|
||||
|
||||
monkeypatch.setattr(settings, "MAX_HOLDINGS_COUNT", 10_000) # 공유 DB에 쌓인 NULL 행과 무관하게
|
||||
|
||||
fake_kis = _FakeKis()
|
||||
ctx = UserContext(
|
||||
user_id=None, username="legacy", mode="virtual", kis=fake_kis,
|
||||
mdd=_FakeMddAlwaysOpen(), loss_tracker=_FakeLossTrackerNeverBanned(),
|
||||
auto_trade_enabled=True, is_legacy=True,
|
||||
)
|
||||
|
||||
async def _fake_resolve(current_user, db):
|
||||
return ctx
|
||||
|
||||
monkeypatch.setattr(registry_module.user_context_registry, "resolve", _fake_resolve)
|
||||
|
||||
captured_history: dict = {}
|
||||
captured_portfolio: dict = {}
|
||||
|
||||
async def _fake_enqueue_trade_history(data):
|
||||
captured_history.update(data)
|
||||
|
||||
async def _fake_enqueue_portfolio_upsert(data):
|
||||
captured_portfolio.update(data)
|
||||
|
||||
monkeypatch.setattr("app.api.endpoints.portfolio_v2.enqueue_trade_history", _fake_enqueue_trade_history)
|
||||
monkeypatch.setattr("app.api.endpoints.portfolio_v2.enqueue_portfolio_upsert", _fake_enqueue_portfolio_upsert)
|
||||
|
||||
stock_code = f"6{_SUFFIX[-5:]}"
|
||||
try:
|
||||
r = await client.post(
|
||||
"/api/v1/portfolio/v2/buy",
|
||||
headers=_auth(_get_api_key()),
|
||||
json={"stock_code": stock_code, "quantity": 1},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert fake_kis.place_order_calls, "safe_place_order_v2가 ctx.kis(fake)를 통해 주문해야 한다"
|
||||
assert "user_id" not in captured_history, "legacy ctx(owner 없음)는 trade_history payload에 user_id를 넣지 않아야 한다"
|
||||
assert "user_id" not in captured_portfolio, "legacy ctx(owner 없음)는 portfolio upsert payload에 user_id를 넣지 않아야 한다"
|
||||
finally:
|
||||
async with AsyncSessionLocal() as db:
|
||||
await db.execute(delete(Portfolio).where(Portfolio.stock_code == stock_code))
|
||||
await db.execute(delete(TradeHistory).where(TradeHistory.stock_code == stock_code))
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# M3 — legacy ctx의 전량 매도는 enqueue_close_active_trade_v2가 아니라 기존
|
||||
# enqueue_close_active_trade(user_id 없음)를 호출해야 한다(ActiveTrade.user_id==owner
|
||||
# 필터로 NULL 행을 못 찾아 유령 포지션이 남는 결함 방지).
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_m3_legacy_sell_full_exit_uses_v1_close_not_v2(client, monkeypatch):
|
||||
from app.services.tenant import registry as registry_module
|
||||
|
||||
fake_kis = _FakeKis()
|
||||
ctx = UserContext(
|
||||
user_id=None, username="legacy", mode="virtual", kis=fake_kis,
|
||||
mdd=_FakeMddAlwaysOpen(), loss_tracker=_FakeLossTrackerNeverBanned(),
|
||||
auto_trade_enabled=True, is_legacy=True,
|
||||
)
|
||||
|
||||
async def _fake_resolve(current_user, db):
|
||||
return ctx
|
||||
|
||||
monkeypatch.setattr(registry_module.user_context_registry, "resolve", _fake_resolve)
|
||||
|
||||
v1_calls = []
|
||||
v2_calls = []
|
||||
|
||||
async def _fake_close_v1(stock_code, strategy_name=None):
|
||||
v1_calls.append((stock_code, strategy_name))
|
||||
|
||||
async def _fake_close_v2(user_id, stock_code, strategy_name=None):
|
||||
v2_calls.append((user_id, stock_code, strategy_name))
|
||||
|
||||
monkeypatch.setattr("app.api.endpoints.portfolio_v2.enqueue_close_active_trade", _fake_close_v1)
|
||||
monkeypatch.setattr("app.api.endpoints.portfolio_v2.enqueue_close_active_trade_v2", _fake_close_v2)
|
||||
|
||||
stock_code = f"5{_SUFFIX[-5:]}"
|
||||
async with AsyncSessionLocal() as db:
|
||||
db.add(Portfolio(
|
||||
user_id=None, stock_code=stock_code, stock_name="레거시보유", quantity=10,
|
||||
average_price=1000, current_price=1000, profit_loss=0, profit_loss_rate=0,
|
||||
market="KRX", strategy_type="manual",
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
r = await client.post(
|
||||
"/api/v1/portfolio/v2/sell",
|
||||
headers=_auth(_get_api_key()),
|
||||
json={"stock_code": stock_code, "quantity": 10},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert v1_calls and v1_calls[0][0] == stock_code
|
||||
assert not v2_calls, "legacy ctx는 enqueue_close_active_trade_v2가 아니라 v1(close_active_trade)을 호출해야 한다"
|
||||
finally:
|
||||
async with AsyncSessionLocal() as db:
|
||||
await db.execute(delete(Portfolio).where(Portfolio.stock_code == stock_code))
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# M5 — MULTI_TENANT_ENABLED=True 에서 legacy owner가 아닌 세션 사용자는 legacy
|
||||
# `/portfolio` 라우터 접근이 403이어야 한다(`deny_non_owner_session_when_mt`).
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_m5_legacy_portfolio_denied_for_non_owner_session_when_mt(client, monkeypatch, two_users):
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "MULTI_TENANT_ENABLED", True)
|
||||
|
||||
# two_users["b"]는 non-admin(legacy owner일 수 없음)
|
||||
r = await client.get("/api/v1/portfolio", headers=_auth(two_users["b_token"]))
|
||||
assert r.status_code == 403, r.text
|
||||
|
||||
r = await client.get("/api/v1/portfolio/v2", headers=_auth(two_users["b_token"]))
|
||||
assert r.status_code != 403, "/portfolio/v2 는 deny_non_owner_session_when_mt 대상이 아니다"
|
||||
269
docs/multi_tenant_design.md
Normal file
269
docs/multi_tenant_design.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# 멀티테넌트(사용자별 매매) 전환 설계
|
||||
|
||||
작성: 2026-09-20 / 상태: 설계(코드 미수정) / 대상 브랜치: `fix/trading-adx-warmup-mlgate` 이후 신규 브랜치 `feat/multi-tenant-phase1`
|
||||
|
||||
---
|
||||
|
||||
## 1. 현황 요약 & 제약
|
||||
|
||||
### 1.1 이미 있는 것
|
||||
| 항목 | 위치 | 비고 |
|
||||
|---|---|---|
|
||||
| `User` 모델 (id, username, password_hash, is_active, is_admin) | `backend/app/models/models.py:390-405`, alembic `0008_add_users_table.py` | 첫 가입자 = 관리자 |
|
||||
| 로그인/세션 토큰 | `backend/app/api/endpoints/auth.py` (register/login/me/users), `app/core/security.py:49-62` (`create_session_token`, `verify_session_token` → username) | JWT HS256, `sub=username`, aud=`autostock-dashboard`, 24h |
|
||||
| 인증 의존성 | `security.py:74` `require_api_key` — X-API-Key 헤더에 **API 키(SECRET_KEY/API_KEY) 또는 세션 토큰** 모두 수용 | 어느 쪽인지 구분은 `auth.py:147 _resolve_user`, `auth.py:155 require_admin`에서만 수행 |
|
||||
| 라우터 등록 | `backend/app/api/__init__.py:23-95` — 라우터 단위 `dependencies=[Depends(require_api_key)]` | 엔드포인트 함수는 사용자를 모름 |
|
||||
|
||||
### 1.2 단일 계좌 전제인 지점 (근거)
|
||||
| 구성요소 | 근거 | 단일 전제 내용 |
|
||||
|---|---|---|
|
||||
| KIS 클라이언트 | `korea_investment_api.py:219-261 __init__`, `:1315 korea_investment_api = KoreaInvestmentAPI()`, `:1320 real_market_api` | `settings.KOREA_INVESTMENT_APP_KEY/SECRET/ACCOUNT_NUMBER`(config.py:389-392에서 KIS_MODE로 결정)만 사용. 모듈 import 시 즉시 인스턴스화 |
|
||||
| 토큰 캐시 | `:178 TOKEN_CACHE_FILE = ~/.cache/auto-stock/kis_token_cache.json`, `:201 kis_token_cache_real.json` | 파일명 고정 → 사용자별 인스턴스가 서로 덮어씀 |
|
||||
| 레이트리미터 | `:229 _throttle_lock`, `:232 min_request_interval=settings.API_MIN_REQUEST_INTERVAL(0.15)`, `:239 semaphore(API_MAX_CONCURRENT_REQUESTS=8)`, `:406 _get_access_token`(_token_lock, `_handle_rate_limit` 1분 제한) | 인스턴스 단위 상태 → 인스턴스 = 앱키 단위로 만들면 자연히 앱키별 제한이 됨 |
|
||||
| 시세 위임 | `:558 _quote_delegate` → `globals().get("real_market_api")`; `KIS_QUOTES_VIA_REAL`(config.py:78) | 조회 API는 계좌와 무관(앱키만 필요). 공용 1개로 공유 가능 |
|
||||
| 테이블 | `models.py` Portfolio(`:158 stock_code unique`), TradeHistory, ActiveTrade, StockBan(`:336 PK=stock_code`), TradeJournal, TradingSession(`:45 session_date unique`), WatchlistStock/History | user_id 컬럼 없음 |
|
||||
| db_worker | `db_worker.py:264 db_writer_worker` — `upsert_portfolio`(:416 stock_code로 SELECT), `upsert_active_trade`(:441 stock_code+status+strategy_name), `close_active_trade`(:483), `upsert_stock_ban`(:351 stock_code) | 조회 키에 user_id 없음 |
|
||||
| trade_executor | `:21 from ... import korea_investment_api`(모듈 전역), `:45 _order_semaphores{stock_code}`, `:95 order_lock:{stock_code}:{order_type}`(Redis), `:169 safe_place_order(stock_code, order_type, quantity, price, is_domestic, exchange_code)`, `:554 get_balance()`, `:1438 mdd_manager.can_open_new_position()`, `:1442 loss_tracker.is_banned_safe()` | 전부 전역 싱글톤 참조 |
|
||||
| mdd_manager | `mdd_manager.py:21-28` peak_balance/is_paused/… + `_lock`, `:15 _MDD_REDIS_KEY="mdd:state"`, `:325 mdd_manager = MDDManager()`, `:200 execute_reduction`은 `korea_investment_api.get_balance()` 직접 호출 | 단일 상태·단일 Redis 키 |
|
||||
| loss_tracker | `loss_tracker.py:27 _tracker{stock_code}`, `:53 save_to_db(db=None, stock_code)` → `enqueue_stock_ban_upsert(stock_code, …)`, `:79 load_from_db(db)` 전체 StockBan 로드, `:171 loss_tracker = _create_loss_tracker()` | 단일 dict |
|
||||
| 스케줄러 | `swing/scheduler.py:79 swing_scheduler()` 60s 루프(`:119 korea_investment_api.get_balance()`, `:107/167/243/249 AsyncSessionLocal()`), `daytrading/scheduler.py:46 daytrading_scheduler()` 30s(`:32 day_trading_state` 싱글톤), `main.py:239-240 create_task` | 프로세스당 루프 1개 |
|
||||
| 데이트레이딩 상태 | `daytrading/position_manager.py:48 DayTradingState` 싱글톤(positions, orb_cache, daily_pnl, loss_streak_today), `daytrading/entry_service.py:69-80 set/get_watchlist` 모듈 전역 | 단일 |
|
||||
| 포트폴리오 캐시 | `portfolio.py:199 PortfolioCache`(holdings, summary, sync_lock) 프로세스 단일 인스턴스, `/portfolio/sync`(:453)가 Portfolio 테이블 직접 커밋(:734-738, CLAUDE.md 허용 예외) | 단일 |
|
||||
| WebSocket | `websocket_manager.py:93 broadcast`/`:105 broadcast_json` → 모든 연결; `main.py:614-618` 토큰 검증만(`is_valid_credential`) 후 `client_id` 등록. 호출지: `trade_executor.py:916,1157`, `portfolio.py:1265` | 사용자 개념 없음 |
|
||||
| MCP/외부 호출자 | `mcp-server/trade_monitor.py:34-35` `X-API-Key = API_KEY or SECRET_KEY`, `:58 /api/v1/portfolio/active-trades/{code}/status` | API 키 호출 = 사용자 미지정 |
|
||||
| 프론트 | `frontend/src/lib/api.ts:65-67` `localStorage.api_key` → X-API-Key; `useWebSocket.ts:83` `?token=`; `App.tsx:109-116` 라우트 6개; `Layout.tsx:62-68` navItems | 계좌 설정 화면 없음 |
|
||||
|
||||
### 1.3 지켜야 할 규칙 (CLAUDE.md)
|
||||
- 기존 함수/클래스 시그니처 변경 금지 → 새 함수는 `_v2`, 새 모듈은 새 파일. 기존 전역 싱글톤(`korea_investment_api`, `mdd_manager`, `loss_tracker`, `db_task_queue`)은 그대로 두고 **legacy owner 사용자의 컨텍스트 별칭**으로 유지.
|
||||
- DB 쓰기는 `db_worker` 경유(예외: `sync_portfolio` 류 단일 트랜잭션 필요 엔드포인트). 실시간 계층 DB I/O 금지.
|
||||
- 리스크 규칙(§5)은 **사용자별 자본 기준**으로 동일 적용. SL 선택 `min()`. 전략 로직 자체는 변경하지 않음(백테스트 불필요 — §8 참고).
|
||||
- `_dumps()` 사용, 쓰기 API `require_api_key`.
|
||||
|
||||
---
|
||||
|
||||
## 2. 목표 아키텍처
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
HTTP/WS 요청 ───▶ │ get_current_user (세션토큰→User / API키→legacy owner)│
|
||||
└──────────────┬───────────────────────────┘
|
||||
▼
|
||||
┌──────────────────────────────────────────┐
|
||||
│ UserContextRegistry (app/services/tenant/registry.py)│
|
||||
│ get(user_id) -> UserContext (캐시, TTL/무효화) │
|
||||
│ iter_active() -> 자동매매 활성 사용자 컨텍스트 목록 │
|
||||
└──────────────┬───────────────────────────┘
|
||||
▼
|
||||
UserContext(user_id, username, kis: KoreaInvestmentAPI(주문·잔고 전용),
|
||||
mode: virtual|real, mdd: MDDManager, loss_tracker: ConsecutiveLossTracker,
|
||||
dt_state: DayTradingState, portfolio_cache: PortfolioCache, settings_override)
|
||||
│
|
||||
┌──────────────────────────┼─────────────────────────────┐
|
||||
▼ ▼ ▼
|
||||
trade_executor_v2(ctx, …) swing/daytrading 스케줄러 db_worker task(data.user_id)
|
||||
safe_place_order_v2(ctx,…) 사용자별 asyncio.Task upsert_* 핸들러 user 필터
|
||||
│
|
||||
▼
|
||||
공용 시세 인스턴스 real_market_api (앱키 1개, 20/s) ← 모든 사용자 공유 (조회 전용)
|
||||
```
|
||||
|
||||
### 2.1 핵심 결정
|
||||
1. **시세(조회) = 공용 1개, 주문·잔고 = 사용자별 인스턴스.** `KoreaInvestmentAPI`의 조회 메서드는 이미 `_quote_delegate()`로 `real_market_api`에 위임하는 구조(`korea_investment_api.py:558-598, 794, 855, 1273`)이며 조회 API는 계좌번호를 사용하지 않는다(`get_current_price` params에 CANO 없음). 따라서 사용자별 인스턴스에는 `_quote_delegate`가 공용 `real_market_api`를 가리키도록 하고, 사용자 앱키는 `place_order`/`get_balance`/`get_order_execution`/`cancel_order`/해외 주문·잔고에만 소비된다. → **사용자 수가 늘어도 시세 트래픽은 앱키 1개(20/s)에 고정**, 사용자별 앱키는 주문·잔고(모의 2/s)만 감당. 단 `KIS_QUOTES_VIA_REAL=False`이거나 운영자가 실전 앱키를 등록하지 않았으면 각 사용자 인스턴스가 자기 앱키로 조회한다(현재 동작과 동일).
|
||||
2. **legacy owner**: `users` 중 `is_admin=True`이고 `id`가 가장 작은 사용자. `.env`의 KIS 자격증명은 이 사용자의 `user_kis_credentials` 행으로 이관(관리자 API). `MULTI_TENANT_ENABLED=False`(기본)이면 현재 전역 싱글톤이 그대로 legacy owner 컨텍스트로 동작.
|
||||
3. **기존 싱글톤 유지**: `korea_investment_api`, `mdd_manager`, `loss_tracker`, `day_trading_state`, `portfolio_cache`는 삭제하지 않고 `UserContextRegistry.legacy()`가 이 인스턴스들을 감싼 컨텍스트를 반환. 기존 호출 코드는 수정 없이 동작.
|
||||
4. **Redis 키·락 네임스페이스에 user_id 포함**: `order_lock:{user_id}:{stock_code}:{order_type}`, `mdd:state:{user_id}`. legacy 컨텍스트(user_id=None 또는 legacy owner id)는 기존 키를 그대로 사용해 하위 호환.
|
||||
5. **db_worker는 새 task 타입을 추가하지 않고 payload `data["user_id"]` 옵션으로 확장**(§3.4 근거).
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터 모델 변경
|
||||
|
||||
### 3.1 신규 테이블 `user_kis_credentials` (models.py에 `UserKisCredential` 추가)
|
||||
| 컬럼 | 타입 | 비고 |
|
||||
|---|---|---|
|
||||
| id | Integer PK | |
|
||||
| user_id | Integer FK users.id ON DELETE CASCADE, **unique** | 사용자당 1세트(모의/실전 동시 등록은 2단계에서 `UniqueConstraint(user_id, mode)`로 완화 가능 — 1단계는 1세트) |
|
||||
| mode | String(10) NOT NULL | `virtual` \| `real` |
|
||||
| app_key_enc | Text NOT NULL | Fernet 암호문 |
|
||||
| app_secret_enc | Text NOT NULL | Fernet 암호문 |
|
||||
| account_no_enc | Text NOT NULL | 계좌 8자리+상품코드 2자리 (`korea_investment_api.py:1006-1007`이 `[:8]`,`[8:]`로 분리하므로 하이픈 제거 후 10자리 저장) |
|
||||
| account_product_code | String(2) NOT NULL default '01' | 검색·표시용 (평문 허용) |
|
||||
| app_key_hint | String(8) | 마스킹 표시용 앞 6자 (`korea_investment_api.py:257`와 동일 마스킹 관례) |
|
||||
| is_enabled | Boolean default False | 자동매매 대상 여부 (등록 직후 False → 연결 테스트 성공 시 True 허용) |
|
||||
| auto_trade_enabled | Boolean default False | 사용자 단위 AUTO_TRADE_ENABLED |
|
||||
| last_verified_at | DateTime | 연결 테스트 성공 시각 |
|
||||
| key_version | Integer default 1 | 암호화 키 로테이션용 (§9) |
|
||||
| created_at / updated_at | DateTime | |
|
||||
|
||||
### 3.2 user_id 추가 대상 (사용자 스코프)
|
||||
| 테이블 | 현 제약 | 변경 | 근거 |
|
||||
|---|---|---|---|
|
||||
| `portfolio` | `stock_code unique`(models.py:158) | `user_id` 추가; unique → `UniqueConstraint(user_id, stock_code, name=uq_portfolio_user_stock)` | 동일 종목을 여러 사용자가 보유 가능 |
|
||||
| `trade_history` | 없음 | `user_id` + `Index(user_id, created_at)` | 조회 필터 (`trades.py:51`) |
|
||||
| `active_trades` | idx stock_code/status(:267,:280) | `user_id` + `Index(user_id, stock_code, status)` | db_worker upsert 키(:445-450)에 user_id 추가 |
|
||||
| `stock_bans` | `PK=stock_code`(:336) | **PK 변경 불가피** → 새 테이블 `stock_bans_v2 (id PK, user_id, stock_code, consecutive_losses, ban_until, updated_at, UNIQUE(user_id, stock_code))`. 기존 `stock_bans`는 legacy owner용으로 남기고 백필 후 읽기 중단 | PK 변경은 기존 코드 시그니처 변경에 해당 → 새 테이블 |
|
||||
| `trade_journals` | 없음 | `user_id` | |
|
||||
| `trading_sessions` | `session_date unique`(:45) | `user_id` + `UniqueConstraint(user_id, session_date)`; 기존 unique 제거 | |
|
||||
| `watchlist_stocks` / `watchlist_history` | 없음 | **공용 유지**(모닝 배치 산출물, 사용자 무관). 사용자별 감시목록이 필요하면 2단계에서 `user_watchlist` 신규 | |
|
||||
| `mcp_tool_calls`, `claude_analysis_log` | 없음 | `user_id` nullable 추가(감사 추적용, 필수 아님) | |
|
||||
|
||||
**공용 유지(변경 없음)**: `stock_daily_prices`, `stock_analysis`, `market_data`, `news`, `exchange_rates`, `us_stock_universe`, `strategy_configs`, `trading_rules`, `strategy_optimization_logs`, `watchlist_*`, `users`.
|
||||
|
||||
### 3.3 Alembic 계획
|
||||
| rev | 내용 |
|
||||
|---|---|
|
||||
| `0009_user_kis_credentials` | `user_kis_credentials` 생성 (+ `ix_user_kis_credentials_user_id` unique) |
|
||||
| `0010_add_user_id_nullable` | portfolio/trade_history/active_trades/trade_journals/trading_sessions/mcp_tool_calls/claude_analysis_log 에 `user_id Integer NULL FK users.id`; 인덱스 추가; `stock_bans_v2` 생성 |
|
||||
| `0011_backfill_legacy_owner` | 데이터 마이그레이션: `legacy_owner_id = SELECT id FROM users WHERE is_admin ORDER BY id LIMIT 1`; 없으면 **스킵**(앱 최초 로그인 후 관리자 API `/auth/admin/tenant/migrate-legacy`가 동일 로직 수행). `UPDATE … SET user_id = :owner WHERE user_id IS NULL`; `INSERT INTO stock_bans_v2 SELECT … FROM stock_bans` |
|
||||
| `0012_user_id_not_null_and_uniques` | `MULTI_TENANT_ENABLED=True` 전환 시점에만 적용: user_id NOT NULL, `portfolio.stock_code` unique 드롭 → `uq_portfolio_user_stock`, `trading_sessions.session_date` unique 드롭 → `(user_id, session_date)` |
|
||||
|
||||
0008과 동일하게 `information_schema` 존재 확인 후 idempotent 작성(`0008_add_users_table.py:19-24` 패턴).
|
||||
|
||||
### 3.4 db_worker 확장 방식 — 결정: **payload에 `user_id` 옵션 추가** (새 task 타입 아님)
|
||||
- 근거: 핸들러가 `data.get(...)`로 키를 꺼내는 구조(`db_worker.py:354-356, 419, 444-445, 487-488`)라 `user_id = data.get("user_id")`가 None이면 기존 쿼리, 있으면 `.where(Model.user_id == user_id)` 추가로 **완전 하위 호환**. 새 task 타입(`upsert_portfolio_v2`)은 12개 핸들러 복제·DLQ(`_save_to_dlq`) 분기·`enqueue_*` 헬퍼 중복을 낳아 오히려 위험.
|
||||
- `enqueue_*` 헬퍼는 시그니처 불변. 사용자 스코프용 새 헬퍼만 추가: `enqueue_stock_ban_upsert_v2(user_id, stock_code, consecutive_losses, ban_until)` (→ `stock_bans_v2` 쓰기, task `upsert_stock_ban_v2` — 이것만 대상 테이블이 다르므로 신규 타입), `enqueue_close_active_trade_v2(user_id, stock_code, strategy_name)`.
|
||||
- `upsert_portfolio`/`upsert_active_trade`/`close_active_trade`/`insert_trade_history`/`insert_trade_journal`: `data["user_id"]` 있으면 필터·컬럼 세팅. `Portfolio(**data)`(:426)·`ActiveTrade(**create_data)`(:459)는 모델에 컬럼이 생기면 그대로 통과.
|
||||
|
||||
---
|
||||
|
||||
## 4. 하위 호환 / 이행 전략
|
||||
|
||||
- `config.py`에 `MULTI_TENANT_ENABLED: bool = False` 추가.
|
||||
- **False**: 스케줄러·trade_executor·API 모두 기존 경로. `get_current_user`는 세션 토큰이든 API 키든 **legacy 컨텍스트**를 반환(user_id는 legacy owner id 또는 None). DB 쓰기 payload에 user_id를 넣되(0010 이후 컬럼 존재) 조회 필터는 적용하지 않음 → 기존 동작 100% 유지.
|
||||
- **True**: `UserContextRegistry`가 `user_kis_credentials.is_enabled=True` 사용자 컨텍스트를 로드, 스케줄러 사용자 순회(2단계), API는 사용자 필터.
|
||||
- 이관 API(관리자): `POST /api/v1/auth/admin/tenant/migrate-legacy` — `.env`의 `KOREA_INVESTMENT_APP_KEY/SECRET/ACCOUNT_NUMBER` + `KIS_MODE`를 legacy owner의 `user_kis_credentials`로 암호화 저장(이미 있으면 409), 0011 백필 로직 재실행. `require_admin`(auth.py:155) 사용.
|
||||
- 전환 순서(H1): **`cd backend && alembic upgrade head`로 0009~0011 적용(반드시 새 이미지 기동 전)** → 관리자 로그인 → migrate-legacy → 다른 사용자 자격증명 등록·연결 테스트 → `MULTI_TENANT_ENABLED=True` + 0012 마이그레이션 적용 → 재기동(`docker compose up -d --build`). `create_all()`은 기존 테이블에 컬럼을 추가하지 않으므로 이 순서를 지키지 않으면(새 이미지 먼저 기동) `portfolio.user_id` 등 신규 컬럼 부재로 레거시 경로 전체가 `UndefinedColumnError`로 실패한다 — `main.py` lifespan이 기동 시 이를 감지해 중단시킨다.
|
||||
- 롤백: `MULTI_TENANT_ENABLED=False`로 되돌리면 legacy 경로. 0012 이전이면 스키마 롤백 불필요.
|
||||
|
||||
---
|
||||
|
||||
## 5. 1단계 상세 (구현 가능 수준)
|
||||
|
||||
### 5.1 자격증명 암복호화 — `backend/app/core/crypto.py` (신규) — **dev-team**
|
||||
- `cryptography` 패키지: `requirements.txt:15 python-jose[cryptography]`의 extras로 이미 설치됨. 명시적으로 `cryptography>=42` 한 줄 추가 권장(암묵 의존 제거).
|
||||
- 키 파생: `_derive_key(secret: str, version: int) -> bytes` = `base64.urlsafe_b64encode(hashlib.pbkdf2_hmac("sha256", secret.encode(), f"autostock-kis-cred-v{version}".encode(), 200_000))`. 소스는 `settings.CREDENTIALS_ENCRYPTION_KEY`(신규, 비어 있으면 `settings.SECRET_KEY` 폴백). SECRET_KEY가 기본값 `change-this-secret-key`(config.py:24)면 저장 API 거부.
|
||||
- `encrypt(plain: str) -> str`, `decrypt(token: str, key_version: int) -> str`. `MultiFernet([current, previous])`로 로테이션: `CREDENTIALS_ENCRYPTION_KEY_PREVIOUS` 설정 시 복호 가능, 관리자 API `POST /auth/admin/tenant/rotate-keys`가 전 행 재암호화 + `key_version` 증가.
|
||||
|
||||
### 5.2 모델·마이그레이션 — **dev-team**
|
||||
- `models.py`: `UserKisCredential`, `StockBanV2` 추가; 스코프 테이블에 `user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)`.
|
||||
- alembic 0009/0010/0011 (§3.3).
|
||||
|
||||
### 5.3 credentials API — `backend/app/api/endpoints/kis_credentials.py` (신규, prefix `/account`) — **dev-team**
|
||||
| 메서드 | 경로 | 동작 |
|
||||
|---|---|---|
|
||||
| GET | `/account/credentials` | 현 사용자 자격증명 마스킹 조회(`app_key_hint + "…"`, 계좌 `****` 뒤 2자리, mode, is_enabled, last_verified_at) |
|
||||
| PUT | `/account/credentials` | 등록/교체 `{mode, app_key, app_secret, account_no}` → 암호화 저장, `is_enabled=False`, 레지스트리 `invalidate(user_id)` |
|
||||
| DELETE | `/account/credentials` | 삭제 + invalidate |
|
||||
| POST | `/account/credentials/verify` | 임시 `KoreaInvestmentAPI` 사용자 인스턴스 생성 → `get_balance()` 호출 성공(rt_cd=0) 시 `last_verified_at` 갱신·`is_enabled=True` 허용. 토큰 발급은 앱키당 1분 1회 제한(`korea_investment_api.py:334 _handle_rate_limit`)이므로 verify는 사용자당 60초 쿨다운 |
|
||||
| PATCH | `/account/auto-trade` | `{enabled}` → `auto_trade_enabled` |
|
||||
- 모두 `Depends(get_current_user)`; API 키 호출자는 legacy owner에 매핑. 자격증명 쓰기는 **엔드포인트에서 단일 트랜잭션 직접 커밋 허용**(sync_portfolio와 같은 성격: 읽기→쓰기 원자성 + 즉시 응답 필요; 실시간 계층 아님). CLAUDE.md §3.2 예외 목록에 추가 기재 필요.
|
||||
|
||||
### 5.4 `get_current_user` 의존성 — `backend/app/core/tenant_auth.py` (신규) — **dev-team**
|
||||
```python
|
||||
@dataclass
|
||||
class CurrentUser: id: int | None; username: str; is_admin: bool; is_api_key: bool
|
||||
|
||||
async def get_current_user(credential: str = Depends(require_api_key), db=Depends(get_db)) -> CurrentUser
|
||||
```
|
||||
- 세션 토큰 → `verify_session_token` → `User` 조회(is_active 확인). API 키(`hmac.compare_digest(credential, _get_api_key())`, auth.py:159 패턴) → `get_legacy_owner(db)`(admin 최소 id; 없으면 `CurrentUser(id=None, username="api-key", is_admin=True, is_api_key=True)`).
|
||||
- 라우터 등록은 그대로 `require_api_key` 유지(`api/__init__.py`), 엔드포인트 함수에 `user: CurrentUser = Depends(get_current_user)` 파라미터를 **추가하는 것은 시그니처 변경**이므로, 사용자 스코프가 필요한 엔드포인트는 `_v2` 경로(§5.6) 또는 새 라우터 파일에 작성.
|
||||
|
||||
### 5.5 `KISClientPool` — `backend/app/services/tenant/kis_pool.py` (신규) — **trading-agent**
|
||||
- `KoreaInvestmentAPI`에 새 classmethod `for_user(user_id, app_key, app_secret, account_no, mode) -> KoreaInvestmentAPI` 추가(기존 `for_real_market_data`(:181-217) 패턴 복제, `__init__` 불변):
|
||||
- `base_url = settings.KIS_REAL_URL if mode=="real" else settings.KIS_VIRTUAL_URL`
|
||||
- `TOKEN_CACHE_FILE = ~/.cache/auto-stock/users/{user_id}/kis_token_{mode}.json` (chmod 600, `:305-316 _write_token_file` 재사용)
|
||||
- `min_request_interval = 0.5 if mode=="virtual" else settings.KIS_REAL_MIN_REQUEST_INTERVAL` (모의 2/s, 실전 20/s)
|
||||
- `_quote_delegate`는 현재 `globals()["real_market_api"]`를 보므로 사용자 인스턴스도 자동으로 공용 시세 인스턴스 사용. 단 `_real is self` 체크(:562)로 재귀 방지 유지.
|
||||
- `KISClientPool.get(user_id) -> KoreaInvestmentAPI`: 복호화 후 생성·캐시(dict + asyncio.Lock). `invalidate(user_id)`는 `await inst.close()`(:278) 후 제거. legacy owner이고 `MULTI_TENANT_ENABLED=False`면 전역 `korea_investment_api` 반환.
|
||||
- 실전 계좌 사용자 인스턴스는 `circuit_breaker`(:249)가 인스턴스별이므로 한 사용자의 연속 실패가 타 사용자를 차단하지 않음.
|
||||
|
||||
### 5.6 `UserContext` / `UserContextRegistry` — `backend/app/services/tenant/context.py`, `registry.py` (신규) — **trading-agent**(구조) + **risk-monitor-agent**(mdd/loss_tracker 팩토리)
|
||||
- `UserContext`: `user_id, username, mode, kis, mdd: MDDManager, loss_tracker: ConsecutiveLossTracker, dt_state: DayTradingState, portfolio_cache: PortfolioCache, auto_trade_enabled`.
|
||||
- `MDDManager`·`ConsecutiveLossTracker`·`DayTradingState`·`PortfolioCache`는 이미 클래스이므로 인스턴스만 새로 만든다(시그니처 불변). 사용자 스코프 영속화는 새 메서드로:
|
||||
- `MDDManager.save_to_redis_v2(user_id)` / `load_from_redis_v2(user_id)` → 키 `mdd:state:{user_id}`.
|
||||
- `ConsecutiveLossTracker.save_to_db_v2(user_id, stock_code)` → `enqueue_stock_ban_upsert_v2`; `load_from_db_v2(db, user_id)` → `StockBanV2` 필터.
|
||||
- `MDDManager.execute_reduction_v2(mdd_action, kis, user_id)` — 현재 `:200`에서 전역 `korea_investment_api.get_balance()`를 쓰므로 kis 주입 버전 필요.
|
||||
- `UserContextRegistry`: `get(user_id)`, `legacy()`(전역 싱글톤 래핑), `iter_active()`(`is_enabled and auto_trade_enabled` 사용자, 60초 TTL 재로드), `invalidate(user_id)`.
|
||||
|
||||
### 5.7 사용자 스코프 API (`_v2`) — **dev-team**
|
||||
| 파일 | 추가 |
|
||||
|---|---|
|
||||
| `portfolio.py` | `GET /portfolio/v2`, `GET /portfolio/v2/summary`, `GET /portfolio/v2/sync` — `user: CurrentUser = Depends(get_current_user)`; Portfolio 쿼리 `.where(Portfolio.user_id == user.id)`; sync는 `ctx.kis.get_balance()` + `ctx.portfolio_cache`. 기존 `/portfolio*`는 legacy 컨텍스트로 유지 |
|
||||
| `manual_buy.py` | `POST /portfolio/v2/buy` → `_buy_stock_impl_v2(request, stock_code, db, ctx)`; `enqueue_trade_history({... "user_id": ctx.user_id})`, `enqueue_portfolio_upsert({... "user_id"})` |
|
||||
| `portfolio.py` `/sell`(:1285) | `POST /portfolio/v2/sell` → `safe_place_order_v2(ctx, …)` |
|
||||
| `trades.py` | 기존 `GET /trades`(:51)에 user 필터 추가는 시그니처 변경 → `GET /trades/v2` |
|
||||
| `swing_portfolio.py`, `day_trading.py` | 2단계 (스케줄러 사용자화와 함께) |
|
||||
| `api/__init__.py` | `kis_credentials.router` 등록(`prefix="/account", dependencies=[Depends(require_api_key)]`) |
|
||||
|
||||
### 5.8 trade_executor 진입점 — **trading-agent**
|
||||
- `safe_place_order_v2(ctx: UserContext, stock_code, order_type, quantity, price=None, is_domestic=True, exchange_code="NASD")`: 락 키 `order_lock:{ctx.user_id}:{stock_code}:{order_type}`(`_acquire_order_lock_v2`), 세마포어 키 `f"{ctx.user_id}:{stock_code}"`(`_get_order_semaphore` 재사용 — 키 문자열만 바뀜, 시그니처 불변), 주문은 `ctx.kis.place_order(...)`. `_auto_cancel_if_pending`도 `ctx.kis.cancel_order`를 쓰는 `_v2`.
|
||||
- 1단계는 수동매수/매도 경로만 `_v2`로 연결. `execute_auto_trade`/`_execute_buy`/`_execute_sell`의 컨텍스트화는 2단계.
|
||||
|
||||
### 5.9 프론트 — **design-team**(UI) + **dev-team**(api.ts 함수)
|
||||
- `frontend/src/lib/api.ts`: `getKisCredentials()`, `putKisCredentials(body)`, `deleteKisCredentials()`, `verifyKisCredentials()`, `patchAutoTrade(enabled)`.
|
||||
- `frontend/src/pages/AccountSettings.tsx` (신규): 모드 라디오(모의/실전 — 실전은 확인 모달), 앱키/시크릿/계좌 입력(마스킹 표시), "연결 테스트" 버튼(60초 쿨다운 안내), 자동매매 토글, 삭제.
|
||||
- `App.tsx:109-116`에 `<Route path="/account" element={<AccountSettings />} />`, `Layout.tsx:62-68` navItems에 `{ path: '/account', icon: KeyRound, label: '내 계좌' }`.
|
||||
- 로그인이 API 키 방식(`localStorage.api_key`)이면 화면에 "운영자(legacy) 계좌" 배지 표시(`/auth/me`의 `auth_type`).
|
||||
|
||||
### 5.10 의존 순서 / 병렬 그룹
|
||||
| 그룹 | 항목 | 담당 | 선행 |
|
||||
|---|---|---|---|
|
||||
| A (병렬) | 5.1 crypto, 5.2 모델+alembic, 5.4 get_current_user, `MULTI_TENANT_ENABLED` config | dev-team | 없음 |
|
||||
| A (병렬) | `KoreaInvestmentAPI.for_user`, 5.5 KISClientPool | trading-agent | 없음 |
|
||||
| A (병렬) | 5.6 mdd/loss_tracker `_v2` 메서드, `StockBanV2` 쓰기 task | risk-monitor-agent | 5.2 모델 정의만 합의 |
|
||||
| A (병렬) | 5.9 AccountSettings UI 목업(api.ts 함수 시그니처 합의 후) | design-team | 없음 |
|
||||
| B | 5.3 credentials API, migrate-legacy 관리자 API | dev-team | A |
|
||||
| B | 5.6 UserContext/Registry 조립 | trading-agent | A |
|
||||
| C | 5.7 `_v2` 엔드포인트, 5.8 `safe_place_order_v2`, db_worker user_id 필터 | dev-team + trading-agent | B |
|
||||
| D | 프론트 연결·라우팅, 통합 테스트 | design-team + dev-team | C |
|
||||
| E | code-verifier 검수(§8) | code-verifier-agent | D |
|
||||
|
||||
---
|
||||
|
||||
## 6. 2단계 개요
|
||||
|
||||
1. **스케줄러 사용자 순회**: `swing_scheduler_v2()`·`daytrading_scheduler_v2()`는 "마스터 루프"로서 `registry.iter_active()`마다 `asyncio.create_task(_run_user_swing_cycle(ctx))`를 관리(사용자별 Task 1개, 종료·예외 시 재생성). 한 사용자의 예외는 자기 Task 안에서 `try/except` → 로그 + 백오프, 타인에 영향 없음. `main.py:239-240`은 `MULTI_TENANT_ENABLED`에 따라 `_v2`를 기동.
|
||||
- 사용자 사이클 내부: `check_and_execute_exit_v2(session)`(swing/exit_service.py:133) 등 기존 함수는 전역 싱글톤을 쓰므로 `ctx`를 받는 `_v3`(`check_and_execute_exit_v3(db, ctx)`, `execute_swing_entries_v2(rec, db, ctx)`, `scan_and_enter_v2(ctx)`, `check_and_exit_positions_v2(ctx)`)로 분리. 추천 데이터(`recommendations:latest`, `stock_analysis`)는 공용 — 사용자별로 재계산하지 않음.
|
||||
- **레이트리밋 스케일**: 사용자 사이클에서 사용자 앱키로 나가는 호출은 `get_balance`(사이클당 1) + 주문·체결확인만. 모의 2/s면 60초 사이클에 최대 120콜 — 20종목 청산체크+진입도 여유. 시세는 공용 20/s 인스턴스로 몰리므로 **사용자 N명 × 보유 종목 수만큼 시세 콜이 증가** → 종목 단위 `quote:{stock_code}` 캐시(이미 존재, TTL 확인) 공유로 중복 제거하고, 마스터 루프에서 사용자 사이클을 `asyncio.Semaphore(MULTI_TENANT_MAX_CONCURRENT_USERS=3)`로 순차화. 토큰은 사용자 앱키별 1개(하루 1회 갱신)이므로 발급 제한 문제 없음.
|
||||
2. **trade_executor 사용자 스코프**: `execute_auto_trade_v2(stock, db, ctx)` → `_execute_buy_v2/_execute_sell_v2(…, ctx)`. 내부의 `korea_investment_api`→`ctx.kis`, `mdd_manager`→`ctx.mdd`, `loss_tracker`→`ctx.loss_tracker`, `enqueue_*` payload에 `user_id`. `_calculate_kelly_position`·사이징은 `ctx.kis.get_balance()`의 총자산 기준 → §5.1 6%/20종목/레버리지 20%가 사용자 자본 기준으로 자동 적용. `correlation_filter`는 사용자 보유 목록을 인자로 받는 `_v2` 필요.
|
||||
3. **db_worker**: §3.4 필터 적용 완료 + `trade_journal`·`trading_sessions` user_id.
|
||||
4. **WebSocket 사용자 채널**: `websocket_manager.connect`(:26) 시 토큰에서 `username`을 뽑아 `self._client_user[client_id] = user_id` 저장(`main.py:614-621`에서 `verify_session_token` 결과 전달). `broadcast_json_to_user(user_id, data)` 신규; `trade_executor.py:916,1157`·`portfolio.py:1265`의 거래 알림은 사용자 채널로, 시세(`send_market_update`)는 기존 브로드캐스트 유지. API 키 접속(운영자)은 전체 수신.
|
||||
5. **데이트레이딩 상태**: `DayTradingState`를 ctx별 인스턴스로, `restore_from_db(db)`(:256) → `restore_from_db_v2(db, user_id)`; `entry_service`의 모듈 전역 watchlist(:69-80)는 공용(모닝 배치 산출) 유지.
|
||||
6. **리스크 규칙 점검 포인트**: MDD -10/-20% 트리거가 `ctx.mdd.update(사용자 총자산)`으로 호출되는지; `execute_reduction_v2`가 `ctx.kis`로 사용자 보유만 축소하는지; 3연패 ban이 `stock_bans_v2(user_id, stock_code)`로 분리되는지; 일일 -3% 손실 한도(`DAY_TRADE_DAILY_LOSS_LIMIT`)가 `ctx.dt_state.daily_pnl` 기준인지; SL은 기존 함수 그대로(`min()`) 재사용.
|
||||
7. **swing_portfolio.py / day_trading.py 엔드포인트 `_v2`**, MCP `trade_monitor.py`는 API 키 = legacy owner 매핑 유지(변경 없음).
|
||||
|
||||
---
|
||||
|
||||
## 7. 리스크 & 열린 질문
|
||||
|
||||
| 항목 | 내용 | 대응 |
|
||||
|---|---|---|
|
||||
| KIS 제한 스케일링 | 시세는 공용 앱키 20/s에 집중. 사용자 10명 × 20종목 = 200종목 시세/사이클 → 10초 소요. | 종목 캐시 공유 + 사용자 사이클 동시성 3 제한 + 사이클 60s 유지. 사용자 30명 이상이면 공용 시세 앱키 2개 라운드로빈(2단계 후속) |
|
||||
| 공용 시세 앱키 부재 | 운영자가 `KIS_REAL_APP_KEY` 미설정이면 `real_market_api=None` → 각 사용자 모의 앱키 2/s로 시세까지 부담 | 멀티테넌트 활성 조건에 `real_market_api is not None` 권고(경고 로그) |
|
||||
| 실전 계좌 오류 격리 | 한 사용자의 잘못된 자격증명이 스케줄러 전체를 막는 것 | 사용자별 Task + 인스턴스별 circuit_breaker; verify 실패 시 `is_enabled=False` 자동 전환(3회 연속 401/403) |
|
||||
| 토큰 캐시 충돌 | 동일 앱키를 두 사용자가 등록하면 KIS가 토큰을 재발급해 서로 무효화 | `user_kis_credentials`에 `app_key_hash(sha256)` 컬럼 + unique → 중복 등록 409 |
|
||||
| 기존 데이터 백필 | 관리자 사용자가 없는 상태에서 0011 실행 | 스킵 후 migrate-legacy API로 지연 백필; user_id NULL 행은 legacy 컨텍스트에서 계속 조회 가능(0012 전) |
|
||||
| 자격증명 유출 | DB 덤프 유출 시 SECRET_KEY만 있으면 복호 가능 | `CREDENTIALS_ENCRYPTION_KEY` 별도 설정 권고, 응답에 평문 절대 미포함, 로그 마스킹(`app_key[:6]`) |
|
||||
| PortfolioCache/`/portfolio/sync` | 사용자별 캐시·락 → 메모리 증가는 미미하나 `sync_lock` 30초 타임아웃(portfolio.py:463)이 사용자별로 병행 | ctx별 PortfolioCache 인스턴스 |
|
||||
| `korea_investment_api_adapter.py`/`app/trading/*` | 별도 클라이언트 계층(`kis_client`)이 존재하나 `app/services`에서는 미사용(`strategy/scanner.py`만 참조) | 본 설계 범위 외. 멀티테넌트에서는 `KoreaInvestmentAPI.for_user`만 사용 |
|
||||
| 열린 질문 | (1) 사용자당 모의+실전 2세트 동시 허용 여부 (1단계: 1세트) (2) 관리자가 타 사용자 포트폴리오 열람 가능해야 하는가(`?user_id=` 관리자 전용 파라미터) (3) 사용자별 전략 파라미터 오버라이드(`strategy_configs`는 공용) 필요 여부 | PM 결정 필요 |
|
||||
|
||||
**테스트 전략**: `backend/tests/test_api_endpoints.py`는 SQLite(:90)라 JSONB/FK CASCADE 검증 불가 → 컨테이너 Postgres(`docker compose run --rm backend pytest -m integration`)용 `tests/integration/test_multi_tenant.py` 신설: (a) 두 사용자 자격증명 등록·마스킹 조회, (b) `upsert_portfolio` user_id 필터로 동일 종목 2행 공존, (c) `stock_bans_v2` 사용자 분리, (d) `safe_place_order_v2` 락 키 분리(fakeredis), (e) `MULTI_TENANT_ENABLED=False`에서 기존 `/portfolio` 응답 불변 스냅샷.
|
||||
|
||||
---
|
||||
|
||||
## 8. 검증 계획
|
||||
|
||||
**code-verifier 체크리스트**
|
||||
1. 기존 함수/클래스 시그니처 diff 0건 (`korea_investment_api.__init__`, `safe_place_order`, `enqueue_*`, `MDDManager`/`ConsecutiveLossTracker` 기존 메서드, 스케줄러 함수).
|
||||
2. 자격증명 API 외 어떤 경로에서도 `AsyncSessionLocal()`/`commit()`이 실시간 계층(콜백·웹소켓·스케줄러 사이클 내부)에 추가되지 않았는지; 추가된 DB 쓰기가 db_worker 경유인지.
|
||||
3. 응답·로그에 app_secret/app_key 평문 없음(grep `app_secret` in logger/return).
|
||||
4. Redis 락 키·mdd 키에 user_id 포함, legacy 경로는 기존 키 문자열 그대로.
|
||||
5. `_v2` 진입 경로의 리스크 게이트: `ctx.mdd.can_open_new_position()`, `ctx.loss_tracker.is_banned_safe()`, `MAX_HOLDINGS_COUNT`가 사용자 보유 수 기준, `record_trade_result_safe()` 뒤 `save_to_db_v2()` 호출.
|
||||
6. SL 계산 함수 재사용 확인(`min()`), `max()` 신규 도입 없음.
|
||||
7. 새 쓰기 엔드포인트 전부 `require_api_key` 라우터 하위 + `get_current_user`; 관리자 API는 `require_admin`.
|
||||
8. `_dumps()` 사용, `json.dumps` 신규 0건.
|
||||
9. alembic 0009~0011 idempotent(재실행 안전), downgrade 작성.
|
||||
10. `MULTI_TENANT_ENABLED=False` 스냅샷 테스트 통과.
|
||||
|
||||
**백테스트 영향**: 없음. 전략 시그널·SL/TP·사이징 공식은 변경하지 않고 실행 컨텍스트(계좌·상태 저장소)만 사용자 단위로 분리하므로 Walk-forward 재검증 불필요. 단 2단계에서 `correlation_filter_v2`가 사용자 보유 기준으로 바뀌는 것은 로직 변경이 아니라 입력 스코프 변경이므로 동일하게 면제. 사이징 기준 자본이 "계좌 총자산"인 점은 기존과 동일.
|
||||
148
docs/multi_tenant_phase1_review.md
Normal file
148
docs/multi_tenant_phase1_review.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# 멀티테넌트 1단계 검증 보고서 (2026-09-21, 브랜치 `dev` 미커밋 변경분)
|
||||
|
||||
검수 범위: `git status` 의 백엔드/프론트 변경 23파일 + 신규 17파일, `docs/multi_tenant_design.md`, CLAUDE.md. 읽기 전용(코드 수정·컨테이너 조작 없음). `ast.parse` 29파일 전부 통과, `npx tsc --noEmit` exit 0. 로컬에 fastapi 미설치로 import 실행 검사 불가 → 정적 순환 임포트 분석으로 대체(순환 없음: `auth.py↔bootstrap.py` 는 양쪽 모두 함수 내 지연 import).
|
||||
|
||||
---
|
||||
|
||||
## High (배포 차단)
|
||||
|
||||
### H1. 배포 순서 결함 — `alembic upgrade head` 없이 새 이미지를 올리면 레거시 경로 전체 500
|
||||
- `backend/app/models/models.py:47,130,165,213,228,278,332` 에 `user_id` 컬럼 추가. `main.py:159` 주석대로 스키마는 `create_all` 만 실행되고 alembic 은 CLI 전용인데, `create_all` 은 기존 테이블에 컬럼을 추가하지 않음. Dockerfile CMD 는 `uvicorn` 직행, compose 에도 alembic 단계 없음.
|
||||
- 영향: SQLAlchemy 는 매핑된 모든 컬럼을 명시 SELECT/INSERT 하므로 `portfolio/trade_history/active_trades/trade_journals/trading_sessions` 관련 모든 기존 엔드포인트와 db_worker 태스크(insert_trade_history, upsert_portfolio 등)가 `UndefinedColumnError` 로 실패. MT 기능과 무관하게 자동매매 기록이 전부 유실됨.
|
||||
- 권장: 배포 절차에 `alembic current` 확인 → `alembic upgrade head` → `docker compose up -d --build` 명문화. `main.py` lifespan 에 `information_schema` 로 `portfolio.user_id` 존재 확인 후 없으면 명확한 에러 로그+기동 중단(또는 경고). 담당: dev(mt-a-dev) + 배포 문서.
|
||||
|
||||
### H2. `get_current_user` 가 비활성/삭제 사용자를 legacy owner 로 폴백 → 회수된 계정이 owner 계좌로 매매 가능
|
||||
- `backend/app/core/tenant_auth.py:87-99`: `_resolve_session_user` 결과가 `None` 또는 `is_active=False` 면 예외 대신 `CurrentUser(id=legacy_owner_id, ...)` 반환. `security.require_api_key`(`security.py:72-91`)는 JWT 서명/exp/aud 만 검증하고 `is_active` 를 보지 않으므로 이 경로는 "비정상 경합"이 아니라 revoke 직후 토큰 만료(24h)까지 항상 도달함.
|
||||
- 영향: MT=True 에서 `registry.resolve()`(`registry.py:160-166`)가 `current_user.id == legacy_ctx.user_id` 로 legacy 컨텍스트를 반환 → 비활성 사용자가 `/portfolio/v2/buy|sell` 로 legacy owner 의 KIS 계좌에 주문, `/portfolio/v2`·`/trades/v2` 로 owner 데이터 열람. MT=False 에서도 owner 데이터 열람.
|
||||
- 권장: 두 경우 모두 `HTTPException(401)` 로 거부. 담당: dev(mt-b-dev).
|
||||
|
||||
### H3. 자격증명 변경/삭제/자동매매 OFF 가 캐시된 `UserContext` 에 반영되지 않음
|
||||
- `backend/app/api/endpoints/kis_credentials.py:183,200` 은 `kis_client_pool.invalidate()` 만 호출. `user_context_registry.invalidate()` 호출처는 코드베이스에 0건(grep 확인). `registry.get()`(`registry.py:59-61`)은 `_contexts` 캐시 히트 시 DB 를 보지 않음.
|
||||
- 영향: PUT(키 교체, `is_enabled=False` 로 리셋)·DELETE 후에도 캐시된 ctx 가 구 app_key 로 만든 KIS 인스턴스를 계속 보유(httpx 는 `is_closed` 체크로 자동 재생성) → 삭제·교체된 자격증명으로 재시작 전까지 주문 계속 가능. `PATCH /auto-trade enabled=false` 도 `ctx.auto_trade_enabled` 에 반영 안 됨 → `buy_stock_v2:270` 가드 무력화.
|
||||
- 권장: 세 엔드포인트에서 `await user_context_registry.invalidate(user_id)` 호출(내부에서 kis_pool 도 정리, 순환 임포트 없음). 담당: dev(mt-b-dev).
|
||||
|
||||
### H4. `tests/integration/test_kis_credentials.py` 가 운영 DB 를 직접 대상으로 함
|
||||
- 파일 docstring(`:4-13`) 자체가 "실제 `AsyncSessionLocal`(운영 DB)에 대해 검증", 실행 예시 `docker exec autostock-backend python -m pytest ...`. 라이브 DB 가드 없음, 테스트 사용자 생성/삭제를 운영 DB 에서 수행. 중간 실패 시 잔여 행.
|
||||
- `test_multi_tenant.py:38-43` 가드는 `os.environ["DATABASE_URL"]` 만 검사 → env 미설정 시 `settings.DATABASE_URL` 기본값(`...:5432/autostock_db`, 컨테이너 내부면 라이브 postgres)으로 떨어지는데 가드를 통과함. `test_h_bootstrap_admin_seed_and_idempotent` 는 `_backfill_legacy_owner_ids` 로 실제 UPDATE 를 수행.
|
||||
- 권장: 두 파일 모두 `settings.DATABASE_URL` 로 가드하고, 임시 DB 마커(예: DB 명 `_test` 접미사) 필수화. 담당: mt-c-dev(테스트).
|
||||
|
||||
---
|
||||
|
||||
## Medium
|
||||
|
||||
### M1. `CurrentUser.effective_user_id`(None) vs `UserContextRegistry.legacy().user_id`(0) 불일치
|
||||
- 위치: `tenant_auth.py:47,85`(None) vs `registry.py:128`(`owner_id = 0`).
|
||||
- 통일 방향: `None`. `users.id=0` 은 존재하지 않으므로 0 은 FK 위반 값이고, `Optional[int]` 타입(`context.py:36`)과도 맞음.
|
||||
- 실제 오동작 경로(관리자 미존재 + API 키 호출, 즉 초기 배포/MCP 서버 호출):
|
||||
- `portfolio_v2.py:268,406` `ctx.user_id is None` 가드를 `0` 이 통과 → 주문 실행 후 `enqueue_trade_history({"user_id": 0})`(`:355,443`) → `users` FK 위반 → db_worker 3회 재시도 후 폐기 → 주문은 나갔는데 거래 기록 유실. `enqueue_portfolio_upsert:373`, `enqueue_close_active_trade_v2:465` 동일.
|
||||
- MT=True 시 `_user_scope_filter(Portfolio.user_id, 0)` → 빈 결과 → 매도 항상 404(fail-closed), 매수는 보유수 0 으로 계산돼 `MAX_HOLDINGS_COUNT` 가드 무의미.
|
||||
- `_resolve_target_user_id` 는 None 이면 400 → GET 계열은 fail-closed(정상).
|
||||
- 권장: `registry.legacy()` 에서 `owner_id = owner.id if owner else None`, owner 미확정 상태면 캐시하지 않기(현재 60초 TTL 동안 0 고정). 담당: mt-a-trading.
|
||||
|
||||
### M2. legacy 컨텍스트가 `safe_place_order_v2` 를 타면 락 키가 기존 경로와 달라 이중 매도 방지가 깨짐
|
||||
- `trade_executor.py` `safe_place_order_v2` 는 항상 `order_lock:{user_id}:{code}:{type}` + 세마포어 키 `{user_id}:{code}` 사용. 자체 docstring 과 설계문서 §2-4/§10-4("legacy 경로는 기존 키 그대로") 위반. MT=False(기본)에서 세션 사용자가 v2 매도 → `order_lock:{owner}:005930:SELL`, 동시에 swing/daytrading exit_service 는 `order_lock:005930:SELL` → 같은 계좌 같은 종목 동시 매도 가능.
|
||||
- 기존 `/portfolio/sell` 도 락이 없었으므로 회귀는 아니지만 설계 의도 미달. 권장: `ctx.is_legacy` 면 `_acquire_order_lock`/`_get_order_semaphore(stock_code)` 로 분기. 담당: mt-a-trading.
|
||||
|
||||
### M3. MT=True 에서 legacy 스케줄러가 쓰는 행(`user_id=NULL`)이 owner 의 v2 화면에서 사라지고 ActiveTrade 종료가 누락
|
||||
- swing/daytrading/trade_executor/portfolio.py/manual_buy.py 어느 곳도 payload 에 `user_id` 를 넣지 않음(grep 0건; 설계 §4 "DB 쓰기 payload에 user_id를 넣되" 미구현). 부트스트랩 백필은 기동 시 1회.
|
||||
- 영향: MT=True 시 `_user_scope_filter` 가 `== owner` 로 엄격해져 기동 이후 자동매매로 생긴 보유/이력이 `/portfolio/v2`, `/trades/v2` 에서 비표시, `sell_stock_v2` 404. MT=False 에서도 `sell_stock_v2:465` 의 `enqueue_close_active_trade_v2(owner, ...)` 가 `ActiveTrade.user_id == owner` 필터(`db_worker.py:587`)로 NULL 행을 못 찾아 전량 수동매도 후 ActiveTrade 가 active 로 잔존 → exit_service 가 유령 포지션 추적.
|
||||
- 권장: 1단계에서는 `enqueue_close_active_trade_v2` 를 legacy ctx 에서 `user_id=None` 으로 호출(기존 동작), 또는 `_user_scope_filter` 를 close 에도 적용. MT=True 전환 전제조건으로 legacy 쓰기 경로 user_id 주입 필요. 담당: mt-a-trading.
|
||||
|
||||
### M4. `registry.get()` 이 `asyncio.Lock` 안에서 DB I/O + Redis + PBKDF2 CPU 작업 수행
|
||||
- `registry.py:63-111`: 락 내부에서 `db.execute`, `decrypt`×3(PBKDF2 200k 회 × 3 ≈ 0.3~0.6초 동기 CPU, `crypto.py:28-32` 파생키 캐시 없음), `load_user_risk_state`(Redis+DB). CLAUDE.md §3-4 위반이며 모든 사용자 컨텍스트 조립을 직렬화 + 이벤트 루프 정지(실시간 스케줄러 지연).
|
||||
- `kis_credentials._to_out:103` 도 GET 마다 `decrypt` 실행 → 요청당 ~0.2초 루프 블로킹.
|
||||
- 권장: `_derive_key` 에 `functools.lru_cache`, 복호화는 `asyncio.to_thread`, 락은 dict 갱신 구간만. 담당: mt-b-dev / mt-a-trading.
|
||||
|
||||
### M5. 프론트 `useTenantMode` 로딩 중 `legacy` 반환 → 세션 사용자에게 owner 데이터 노출/오주문 창
|
||||
- `frontend/src/lib/tenant.ts:37`. `usePortfolioData` 는 `['portfolio-combined','legacy']` 키로 `/portfolio/combined` 를 즉시 호출해 결과를 캐시. MT=True 에서 이는 타 사용자(owner) 보유 정보 노출이며, 그 창에 매수/매도 클릭 시 `useTradeActions` 가 legacy `/portfolio/sell` 로 owner 계좌에 주문.
|
||||
- 근본 원인은 백엔드: legacy `/portfolio*`, `/trades*` 가 MT=True 에서도 모든 세션 사용자에게 열려 있음(설계 §5.7 "기존 /portfolio* 는 legacy 컨텍스트로 유지"). 프론트 가드는 완화책일 뿐.
|
||||
- 권장: (프론트) `mode: 'v2' | 'legacy' | undefined`, 로딩 중 쿼리 `enabled: false`. (백엔드, MT=True 전환 전제) legacy 라우터에 "MT=True 이고 세션 사용자가 owner 아니면 403" 의존성. 담당: mt-d-dev / mt-b-dev.
|
||||
|
||||
### M6. `resetPendingTrades` → `DELETE /trades/pending` 가 사용자 무관 전체 삭제
|
||||
- `useTradeActions.ts:97-99` v2 전환 없이 legacy 유지, `trades.py:192-209` 는 `user_id` 조건 없음 → MT=True 에서 일반 사용자가 타 사용자의 PENDING/FAILED 이력 삭제 가능. 권장: `/trades/v2/pending` 추가 또는 v2 모드에서 버튼 숨김. 담당: mt-d-dev.
|
||||
|
||||
### M7. db_worker `scalar_one_or_none()` 이 사용자 다중화로 `MultipleResultsFound` 가능
|
||||
- `db_worker.py:543,589`(`upsert_active_trade`/`close_active_trade`) legacy 호출(`user_id=None`) 시 필터 미적용인데 MT 로 두 사용자가 같은 종목 active 면 예외 → 태스크 3회 실패 후 폐기. `user_id=None` 경로 자체는 기존과 동일(`is not None` 체크 정확 — `== None` 실수 없음). 권장: `.limit(1)` 또는 `scalars().first()`. 담당: mt-e-risk-fix.
|
||||
|
||||
### M8. 부트스트랩/이관 API 의 백필이 첫 실패 후 조용히 전부 무효화
|
||||
- `bootstrap.py:122-146`, `auth.py:337-349`: PostgreSQL 은 트랜잭션 내 한 문장 실패 시 이후 문장이 모두 `InFailedSqlTransaction` → 개별 try/except 가 삼켜 `backfilled` 카운트가 0 으로 오보되고 실제 UPDATE 미실행. 권장: 문장별 `SAVEPOINT`(`session.begin_nested()`) 또는 사전 `information_schema` 체크. 담당: mt-b-dev.
|
||||
|
||||
---
|
||||
|
||||
## Low
|
||||
|
||||
- L1. `mdd_manager.py:159` `json.dumps` 신규 사용(§4 `_dumps()` 규칙). 기존 `:44` 도 동일 패턴이라 일관성 차원.
|
||||
- L2. `crypto.decrypt` `ValueError` → `registry.get:80` 은 "계좌 미등록" 400 으로 안내(원인 은폐), `kis_credentials._to_out:103` 은 미처리 → 500. 키 로테이션 실패 시 진단 어려움.
|
||||
- L3. Alembic `0009` downgrade `drop_index("ix_user_kis_credentials_app_key_hash")` — 테이블이 `create_all` 로 생성된 경우 unique 는 constraint(`..._app_key_hash_key`)라 인덱스가 없어 downgrade 실패. `0010` idempotent OK, FK `ondelete` 모델과 일치 OK(SET NULL / CASCADE). 사용자 삭제 엔드포인트가 없어 CASCADE↔SET NULL 정책 충돌은 현재 비발생.
|
||||
- L4. `registry.legacy()` 의 `auto_trade_enabled=settings.AUTO_TRADE_ENABLED` 스냅샷은 60초 TTL 동안 고정 — 런타임 토글 지연.
|
||||
- L5. `kis_credentials.verify_credentials:235` `detail=f"...{e}"` 로 KIS 예외 원문 노출 — 현재 KIS 오류 본문에 키는 없으나 방어적으로 예외 타입만 노출 권장. 평문 키/시크릿/비밀번호는 응답·로그 어디에도 없음 확인(hint 6자만, `BOOTSTRAP_ADMIN_PASSWORD` 미로깅).
|
||||
|
||||
---
|
||||
|
||||
## 확인 통과 항목
|
||||
- 시그니처 변경: `git diff` `-` 라인에 def/class 변경 0건. 신규는 전부 `_v2`/`for_user`/신규 파일.
|
||||
- 실시간 계층 `AsyncSessionLocal()` 신규 호출 없음(bootstrap 은 lifespan 1회, 허용).
|
||||
- 직접 commit 신규 경로: `kis_credentials`(설계 §5.3 허용), `sync_portfolio_v2`(sync 예외), `sell_stock_v2:469`(기존 `/portfolio/sell:1365` 와 동일 패턴). 정책 내.
|
||||
- `require_api_key`: `portfolio_v2`/`trades_v2`/`kis_credentials` 라우터 등록 시 의존성 부여(`api/__init__.py:30-52`). `/auth/admin/tenant/*` 는 `require_admin`(내부적으로 `require_api_key` 의존) 강제.
|
||||
- 관리자 `?user_id=` 오버라이드: `_resolve_target_user_id:69`/`_resolve_ctx:80` 비관리자 403, GET 2개 + trades_v2 모두 경유.
|
||||
- `app_key_hash` 중복 체크 `user_id != user_id` 로 자기 갱신 오탐 없음(`:151`).
|
||||
- JWT: `aud`/`exp` 검증(`security.py:58`).
|
||||
- `.env` 미추적(`.env.example` 만), `git diff --cached` 비어 있음.
|
||||
- `sync_portfolio_v2` SELECT/INSERT/DELETE 모두 `Portfolio.user_id == ctx.user_id`(`:193,219,230-232`).
|
||||
- `execute_reduction_v2` 큐 payload 3곳 모두 `user_id` 포함.
|
||||
- `registry.get()` `is_enabled=False` 거부(`:72`), `invalidate` 시 `_active_cache=None`(`:214`).
|
||||
- `for_user` 속성 집합 = `for_real_market_data` ⊇ + `mode`,`user_id`; `_last_token_request_time` 은 `hasattr` 가드.
|
||||
- SL 관련 `max()` 신규 도입 없음.
|
||||
|
||||
## CLAUDE.md §8.1 체크리스트
|
||||
1. DB 쓰기 db_worker 경유 — ✅ (허용 예외만 직접 commit)
|
||||
2. §5 리스크 규칙 — ✅ (buy_v2 에 가격필터/ban/MDD/최대보유 적용) — 단 M1 로 legacy·무관리자 시 보유수 가드 무의미
|
||||
3. SIDEWAYS 진입 제안 금지 — 해당 없음 ✅
|
||||
4. 백테스트 없이 실전 투입 금지 — 전략 변경 없음 ✅
|
||||
5. SL `min()` 만 — ✅
|
||||
6. `record_trade_result` 후 `save_to_db` — v2 경로에 record 호출 없음(해당 없음) ✅
|
||||
7. 쓰기 API `require_api_key` — ✅
|
||||
8. `_dumps()`/새 파일/시그니처 불변 — ❌ (L1 `json.dumps` 1건; 나머지 ✅)
|
||||
|
||||
## 배포 가능 여부
|
||||
**불가.** High 4건(H1 배포 순서, H2 비활성 사용자 폴백, H3 컨텍스트 캐시 미무효화, H4 운영 DB 테스트). H1~H3 는 각 1~5줄 수정으로 해소 가능. MULTI_TENANT_ENABLED=True 전환은 추가로 M1·M3·M5(백엔드 legacy 라우터 격리) 해소가 전제.
|
||||
|
||||
---
|
||||
|
||||
# 재검증 (2026-09-21, 4팀 수정 반영 후 `dev` 워킹트리)
|
||||
|
||||
읽기 전용 재검수. `ast.parse` 30파일 OK, `npx tsc --noEmit` exit 0. `git diff` `-` 라인 재점검: 함수/클래스 시그니처 변경 없음(`portfolio.py` `PortfolioResponse` 필드 `Optional[...]` 화는 기존 알려진 pydantic v2 버그 수정, 별도 항목). 순환 임포트 신규 없음(`api/__init__.py → core/tenant_auth`, `kis_credentials → services/tenant/registry` 모두 endpoints 로 되돌아오지 않음).
|
||||
|
||||
## 항목별 판정
|
||||
|
||||
| 항목 | 판정 | 근거 |
|
||||
|---|---|---|
|
||||
| H1 alembic 배포 순서 | ✅ 해소 | `main.py:90-109` `information_schema` 검사 — `create_all`(`:87`) 직후, 부트스트랩(`:184`) 이전, `try` 블록 내 `raise RuntimeError` → 기동 중단. `Jenkinsfile:81-88` DB Migration 스테이지가 Deploy(`:90`) 앞으로 이동, `\|\| echo WARNING` 제거로 실패 시 파이프라인 중단. `docker-compose run --rm --no-deps backend alembic upgrade head`: compose `backend` 서비스 `env_file: .env`(DEPLOY_DIR 기준) + `autostock-network` 부착 + 컨테이너 내 `/.dockerenv` 존재로 `config._apply_docker_db_override` 가 `localhost→postgres` 치환(`config.py:369-372`), `WORKDIR /app` 에 `alembic.ini` 존재 → NAS 환경 동작 가능. 단 전제 2건은 아래 N1 참조 |
|
||||
| H2 비활성 사용자 폴백 | ✅ 해소 | `tenant_auth.py:97-105` `user is None or not is_active` → 401. API 키 경로(`:76-85`) 변경 없음 — legacy owner 매핑 유지, owner 미존재 시 `id=None, is_api_key=True` |
|
||||
| H3 컨텍스트 캐시 미무효화 | ✅ 해소 | `kis_credentials.py:201,218,312` PUT/DELETE/PATCH 모두 `user_context_registry.invalidate(user_id)` (내부에서 `kis_client_pool.invalidate` 호출, `registry.py:226-231`) |
|
||||
| H4 운영 DB 테스트 | ✅ 해소 | `tests/integration/conftest.py:44-52` `settings.DATABASE_URL` DB명 `_test` 접미사 강제, 미충족 시 `pytest.exit`. 두 테스트 파일이 공용 conftest 경유. 부작용 N3 참조 |
|
||||
| M1 effective_user_id vs legacy 0 | ✅ 해소 | `registry.py:147-172` `owner_id: int \| None = None`, `_legacy_owner_confirmed` 로 owner 미확정 시 TTL 캐시 미적용. `portfolio_v2.py:59-73` `_user_scope_filter` MT=True & None → `sql_false()`(fail-closed), `column == None` 의 `IS NULL` 컴파일 함정 회피. buy/sell 가드 `ctx.user_id is None and not ctx.is_legacy`(`:282`) |
|
||||
| M2 legacy 락 키 | ✅ 해소 | `trade_executor.py` `safe_place_order_v2` `ctx.is_legacy` 분기 → `_acquire_order_lock`/`_release_order_lock`/`_get_order_semaphore(stock_code)` (v1 과 동일 키) |
|
||||
| M3 legacy 행 user_id 정합 | 🟡 부분 | (1단계) `portfolio_v2.py:386-407,485-496` legacy ctx 는 trade_history/portfolio payload 에 `user_id` 미포함 + `enqueue_close_active_trade`(v1) 사용 → 기존 v1 과 동일 ✅. (MT=True 전제) 스윙/데이트레이딩 스케줄러 쓰기 경로에 `user_id` 주입은 여전히 없음 → MT=True 에서 `_user_scope_filter == owner` 가 기동 이후 자동매매 행(NULL)을 owner 의 v2 화면에서 제외. 설계 §4 미구현 그대로 — MT=True 전환 전 필수 |
|
||||
| M4 락 내 I/O·PBKDF2 | ✅ 해소 | `registry.py:57-116` 락은 dict 갱신(더블체크)만, `decrypt` 는 `asyncio.to_thread`, `crypto.py:36` `_derive_key` `lru_cache`. 동일 user_id 동시 요청: `kis_client_pool.get_or_create` 가 자체 락+캐시로 KIS 인스턴스 단일 보장 → 이중 생성/누수 없음(중복 조립되는 MDD/loss_tracker 객체는 패자 측이 버려짐, 외부 자원 없음). `kis_credentials.py:100-119` `_to_out` 도 to_thread |
|
||||
| M5 프론트 legacy 플래시 / 백엔드 legacy 라우터 격리 | 🟡 부분 | 프론트: `tenant.ts:30-55` `mode: 'v2'\|'legacy'\|undefined`, `usePortfolioData.ts:100` `enabled: mode !== undefined`, `useTradeActions.ts:48` `isReady`, `Dashboard.tsx:797,838,844` 버튼 disabled, `TradeHistory.tsx:352` 동일 ✅. `/auth/me`(`auth.py:250-258`) API 키 → `auth_type: "api_key"` → legacy 정상 ✅. 백엔드: `api/__init__.py:44,48,100` `portfolio`/`manual_buy`/`trades` 라우터에 `deny_non_owner_session_when_mt`(`tenant_auth.py:108-128`: MT=False 통과, `is_api_key` 통과 → MCP 서버 영향 없음, 스케줄러는 HTTP 를 타지 않아 무관) ✅. 잔여: `swing_portfolio`/`day_trading`/`daytrading_v2`/`us_trading`/`journals`/`risk_management`/`recommendations` 등 나머지 legacy 라우터는 MT=True 에서 여전히 모든 세션에 노출(설계 §7-3 2단계 이관 대상) — MT=True 전환 전제 |
|
||||
| M6 DELETE /trades/pending 전체 삭제 | ✅ 해소 | `trades_v2.py:96-128` `/trades/v2/pending` `_user_scope_filter` 적용, `useTradeActions.ts:114-119` v2 분기 |
|
||||
| M7 scalar_one_or_none MultipleResultsFound | ✅ 해소 | `db_worker.py:540-550,592-602` legacy(user_id None) 경로 `order_by(id.desc()).limit(1)` + `scalars().first()` |
|
||||
| M8 백필 InFailedSqlTransaction | ✅ 해소 | `bootstrap.py:128-146` 문장별 `begin_nested()`, `auth.py:336-345` 동일 |
|
||||
| L1 json.dumps | ✅ 해소 | `mdd_manager.py:13,46,161` `_dumps` |
|
||||
| L2 decrypt 오류 진단 | ✅ 해소 | `crypto.py:29` `CredentialDecryptError`, `registry.py:83-87` 400 "복호화 실패" 명시, `_to_out` `decrypt_error` 플래그 |
|
||||
| L3 0009 downgrade | ✅ 해소 | `0009:50-66` `pg_indexes` 존재 확인 후 drop |
|
||||
| L4 AUTO_TRADE_ENABLED 스냅샷 | ✅ 해소 | `context.py:48-` `effective_auto_trade_enabled` 프로퍼티, `portfolio_v2.py:286` 사용 |
|
||||
| L5 KIS 예외 원문 노출 | ✅ 해소 | `kis_credentials.py:260` `type(e).__name__` 만 노출 |
|
||||
|
||||
## 신규 발견 (수정 과정 부작용 / 전제조건)
|
||||
|
||||
- **N1 (Medium, 배포 전제조건)** `alembic upgrade head` 의 `alembic_version` 선행 상태. 이전 파이프라인의 `docker-compose exec ... \|\| echo WARNING` 이 실제 성공했는지 불명. 운영 DB 가 `create_all` 로만 만들어져 `alembic_version` 이 비어 있으면 `upgrade head` 가 0001 부터 실행되고, `0003_add_strategy_type.py:23-53` `add_column`/`create_index` 는 존재 가드가 없어 `DuplicateColumn` 으로 실패 → 이제는 파이프라인이 중단(안전하지만 배포 불가). 첫 배포 전 `alembic current` 확인 후 비어 있으면 `alembic stamp 0008` 필요 — Jenkinsfile 또는 배포 문서에 명시 권장. 또한 `--no-deps` 는 postgres 컨테이너가 이미 기동 중임을 전제(최초 배포 환경에선 Deploy 이전이라 실패).
|
||||
- **N2 (Low)** `get_current_user` 가 `deny_non_owner_session_when_mt` 경유로 모든 `/portfolio*`·`/trades*` 요청에 붙어 요청당 DB 쿼리 2회(`get_legacy_owner`, 사용자 조회) 추가 — MT=False 에서도 실행됨(플래그 검사가 의존성 해석 뒤). 30초 폴링 수준에선 무시 가능하나 `MULTI_TENANT_ENABLED=False` 면 `get_current_user` 를 건너뛰는 조기 반환으로 줄일 수 있음.
|
||||
- **N3 (Low)** `tests/integration/conftest.py:47` `pytest.exit` 는 `backend/` 에서 인자 없이 `pytest` 실행(단위 테스트 전체 수집) 시에도 발동 → 라이브 DB 를 안 건드리는 기존 단위 테스트 실행까지 중단. `pytest.ini` 에 `testpaths = tests` + `norecursedirs = tests/integration`(또는 `addopts = --ignore=tests/integration`) 로 기본 수집에서 제외하고 통합 테스트는 명시 경로로만 실행하도록 권장. Jenkins 는 pytest 를 실행하지 않아 CI 영향 없음.
|
||||
- **N4 (Low, UX)** `useTenantMode` 가 `/auth/me` 실패(네트워크 오류 등) 시 `undefined` 를 유지하고 `staleTime` 5분·`refetchOnWindowFocus:false` 라 재시도 소진 후 대시보드가 빈 상태·버튼 비활성으로 고정될 수 있음. `retry`/수동 재시도 버튼 고려.
|
||||
|
||||
## 최종 판정
|
||||
|
||||
**배포 가능(MULTI_TENANT_ENABLED=False 기본값 기준).** High 4건 모두 해소, Medium 8건 중 6건 해소·2건(M3, M5) 은 1단계 범위 내 부분 해소이며 잔여분은 MT=True 전환 전제조건. 배포 시 N1(`alembic current` 확인/필요 시 `stamp 0008`) 을 먼저 수행할 것.
|
||||
**MULTI_TENANT_ENABLED=True 전환은 아직 불가** — M3 잔여(legacy 스케줄러 쓰기 경로 `user_id` 주입 또는 owner 에 대해 NULL 행 포함 필터), M5 잔여(나머지 legacy 라우터 격리) 해소 후.
|
||||
@@ -27,6 +27,7 @@ const MCPStatus = React.lazy(() => import('./pages/MCPStatus'))
|
||||
const StrategySettings = React.lazy(() => import('./pages/StrategySettings'))
|
||||
const Backtest = React.lazy(() => import('./pages/Backtest'))
|
||||
const Analysis = React.lazy(() => import('./pages/Analysis'))
|
||||
const AccountSettings = React.lazy(() => import('./pages/AccountSettings'))
|
||||
|
||||
/** ErrorBoundary 상태 인터페이스 */
|
||||
interface ErrorBoundaryState {
|
||||
@@ -113,6 +114,7 @@ function App() {
|
||||
<Route path="/strategy" element={<StrategySettings />} />
|
||||
<Route path="/backtest" element={<Backtest />} />
|
||||
<Route path="/analysis" element={<Analysis />} />
|
||||
<Route path="/account" element={<AccountSettings />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
|
||||
59
frontend/src/components/AccountSetupNotice.tsx
Normal file
59
frontend/src/components/AccountSetupNotice.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* AccountSetupNotice.tsx — 계좌 미등록/미검증 빈 상태 안내 컴포넌트
|
||||
*
|
||||
* 사용처:
|
||||
* - PortfolioSummary / PortfolioPanel: v2 API가 400(계좌 미등록·미검증)을 반환할 때
|
||||
* - Dashboard: 매수/매도 요청이 400/423(자동매매 비활성)로 거부될 때
|
||||
*
|
||||
* 정책: 에러 토스트 대신 인라인 안내 + `/account` 이동 버튼 (일회성 alert 금지).
|
||||
*/
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ShieldAlert } from 'lucide-react'
|
||||
|
||||
interface AccountSetupNoticeProps {
|
||||
/** 안내 문구 — 백엔드 detail 메시지를 그대로 넘기거나 기본 문구 사용 */
|
||||
message?: string
|
||||
/** 컴팩트 배너(한 줄) 모드 — 목록/버튼 인근처럼 좁은 영역용 */
|
||||
compact?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const DEFAULT_MESSAGE = 'KIS 계좌가 등록·검증되지 않았습니다. 내 계좌 설정에서 계좌를 등록·검증하세요.'
|
||||
|
||||
const AccountSetupNotice = ({ message, compact = false, className = '' }: AccountSetupNoticeProps) => {
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-sm border border-yellow-500/30 bg-yellow-500/10 text-yellow-400 text-[10px] ${className}`}
|
||||
role="alert"
|
||||
>
|
||||
<ShieldAlert size={12} className="flex-shrink-0" />
|
||||
<span className="flex-1 leading-tight">{message || DEFAULT_MESSAGE}</span>
|
||||
<Link
|
||||
to="/account"
|
||||
className="flex-shrink-0 px-2 py-0.5 rounded-sm border border-yellow-500/40 hover:bg-yellow-500/20 transition-colors whitespace-nowrap"
|
||||
>
|
||||
계좌 설정
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/10 text-yellow-400 text-xs mb-3 ${className}`}
|
||||
role="alert"
|
||||
>
|
||||
<ShieldAlert size={16} className="flex-shrink-0" />
|
||||
<span className="flex-1">{message || DEFAULT_MESSAGE}</span>
|
||||
<Link
|
||||
to="/account"
|
||||
className="flex-shrink-0 px-3 py-1.5 rounded-md border border-yellow-500/40 hover:bg-yellow-500/20 transition-colors font-medium whitespace-nowrap"
|
||||
>
|
||||
내 계좌 설정으로 이동
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountSetupNotice
|
||||
@@ -25,6 +25,8 @@ interface JournalPanelProps {
|
||||
tradeHistory?: TradeHistory[] | undefined
|
||||
/** 미체결 주문 정리 콜백 */
|
||||
onResetPending: () => void
|
||||
/** true면 미체결 정리 버튼 비활성화 (예: 사용자 모드 확정 전) */
|
||||
resetDisabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,7 +35,7 @@ interface JournalPanelProps {
|
||||
* 상태(State):
|
||||
* - tab: 현재 활성 탭 ('trades' 또는 'journal')
|
||||
*/
|
||||
const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending }: JournalPanelProps) => {
|
||||
const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, resetDisabled }: JournalPanelProps) => {
|
||||
const [tab, setTab] = useState<'trades' | 'journal'>('trades')
|
||||
|
||||
return (
|
||||
@@ -69,7 +71,8 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending }: Jou
|
||||
</div>
|
||||
<button
|
||||
onClick={onResetPending}
|
||||
className="px-2 py-0.5 text-[9px] font-mono text-[#3a5570] hover:text-[#e84444] border border-[#152035] hover:border-[#e84444]/30 rounded-sm transition-colors"
|
||||
disabled={resetDisabled}
|
||||
className="px-2 py-0.5 text-[9px] font-mono text-[#3a5570] hover:text-[#e84444] border border-[#152035] hover:border-[#e84444]/30 rounded-sm transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-[#3a5570] disabled:hover:border-[#152035]"
|
||||
title="미체결 주문 정리"
|
||||
>
|
||||
미체결 정리
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* [Main] — 페이지 컨텐츠 영역
|
||||
*/
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { LayoutDashboard, History, Cpu, Activity, SlidersHorizontal, BarChart3, TrendingUp } from 'lucide-react'
|
||||
import { LayoutDashboard, History, Cpu, Activity, SlidersHorizontal, BarChart3, TrendingUp, KeyRound } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '../lib/api'
|
||||
|
||||
@@ -66,6 +66,7 @@ const Layout = ({ children }: LayoutProps) => {
|
||||
{ path: '/backtest', icon: BarChart3, label: '백테스트' },
|
||||
{ path: '/trades', icon: History, label: '매매 로그' },
|
||||
{ path: '/mcp', icon: Cpu, label: 'MCP 상태' },
|
||||
{ path: '/account', icon: KeyRound, label: '내 계좌' },
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -17,6 +17,7 @@ import React from 'react'
|
||||
import { Activity, TrendingUp, Clock } from 'lucide-react'
|
||||
import { Position } from '../types/trading'
|
||||
import { isUsStock } from '../utils/stockUtils'
|
||||
import AccountSetupNotice from './AccountSetupNotice'
|
||||
|
||||
/**
|
||||
* 가격 포맷 — 시장에 따라 원화 또는 달러 표시
|
||||
@@ -44,6 +45,8 @@ interface PortfolioItemProps {
|
||||
onRefetchAiAnalysis: () => void
|
||||
/** 전량 청산(시장가 매도) 콜백 */
|
||||
onSell: (stockCode: string, stockName: string, quantity: number) => Promise<void>
|
||||
/** true면 청산 버튼 비활성화 (예: 사용자 모드 확정 전) */
|
||||
sellDisabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,6 +67,7 @@ const PortfolioItem = React.memo(({
|
||||
onSelectStock,
|
||||
onRefetchAiAnalysis,
|
||||
onSell,
|
||||
sellDisabled,
|
||||
}: PortfolioItemProps) => {
|
||||
// 현재가: 실시간 가격 우선, 없으면 포트폴리오 API 가격 사용
|
||||
const currentPrice = livePrices?.[stock.stock_code] ?? Number(stock.current_price || 0)
|
||||
@@ -79,10 +83,13 @@ const PortfolioItem = React.memo(({
|
||||
|
||||
const handleSellClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
if (sellDisabled) return
|
||||
try {
|
||||
await onSell(stock.stock_code, stock.stock_name, quantity)
|
||||
} catch (error: unknown) {
|
||||
const err = error as any
|
||||
// 계좌 미등록/미검증(400/423)은 Dashboard 상단 인라인 안내로 처리 — alert 생략
|
||||
if (err?.isAccountSetupRequired) return
|
||||
alert(`매도 실패: ${err.response?.data?.detail || err.message}`)
|
||||
}
|
||||
}
|
||||
@@ -133,7 +140,8 @@ const PortfolioItem = React.memo(({
|
||||
<div className="px-3 pb-2">
|
||||
<button
|
||||
onClick={handleSellClick}
|
||||
className="w-full py-0.5 text-[9px] font-mono text-[#3a5570] hover:text-[#3d7eff] border border-[#152035] hover:border-[#3d7eff]/40 rounded-sm transition-colors"
|
||||
disabled={sellDisabled}
|
||||
className="w-full py-0.5 text-[9px] font-mono text-[#3a5570] hover:text-[#3d7eff] border border-[#152035] hover:border-[#3d7eff]/40 rounded-sm transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-[#3a5570] disabled:hover:border-[#152035]"
|
||||
>
|
||||
전량 청산 (시장가)
|
||||
</button>
|
||||
@@ -158,6 +166,12 @@ interface PortfolioPanelProps {
|
||||
onRefetchAiAnalysis: () => void
|
||||
/** 전량 청산 콜백 */
|
||||
onSell: (stockCode: string, stockName: string, quantity: number) => Promise<void>
|
||||
/** true면 청산 버튼 비활성화 (예: 사용자 모드 확정 전) */
|
||||
sellDisabled?: boolean
|
||||
/** true면 KIS 계좌 미등록/미검증 — 빈 목록 대신 계좌 설정 안내 표시 */
|
||||
accountSetupRequired?: boolean
|
||||
/** 계좌 미등록/미검증 안내 문구 (accountSetupRequired=true일 때만 사용) */
|
||||
accountSetupMessage?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,6 +188,9 @@ const PortfolioPanel = React.memo(({
|
||||
onSelectStock,
|
||||
onRefetchAiAnalysis,
|
||||
onSell,
|
||||
sellDisabled,
|
||||
accountSetupRequired,
|
||||
accountSetupMessage,
|
||||
}: PortfolioPanelProps) => {
|
||||
const dayStocks = portfolio?.filter(s => s.strategy_type === 'daytrading') ?? []
|
||||
const swingStocks = portfolio?.filter(s => s.strategy_type !== 'daytrading') ?? []
|
||||
@@ -197,6 +214,7 @@ const PortfolioPanel = React.memo(({
|
||||
onSelectStock={onSelectStock}
|
||||
onRefetchAiAnalysis={onRefetchAiAnalysis}
|
||||
onSell={onSell}
|
||||
sellDisabled={sellDisabled}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -224,7 +242,11 @@ const PortfolioPanel = React.memo(({
|
||||
|
||||
{/* List */}
|
||||
<div className="divide-y divide-[#0f1e2e] overflow-y-auto custom-scrollbar flex-1">
|
||||
{total > 0 ? (
|
||||
{accountSetupRequired ? (
|
||||
<div className="h-full flex flex-col items-center justify-center p-3">
|
||||
<AccountSetupNotice message={accountSetupMessage} compact />
|
||||
</div>
|
||||
) : total > 0 ? (
|
||||
<>
|
||||
{renderGroup(dayStocks, '데이트레이딩', <Clock className="w-2.5 h-2.5 text-[#f97316]" />, 'text-[#f97316]')}
|
||||
{renderGroup(swingStocks, '스윙', <TrendingUp className="w-2.5 h-2.5 text-[#a78bfa]" />, 'text-[#a78bfa]')}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
import { AlertTriangle, TrendingUp, TrendingDown, Wallet, BarChart3, Calendar, ArrowUpDown } from 'lucide-react';
|
||||
import { PortfolioSummary as PortfolioSummaryType } from '../types/trading';
|
||||
import AccountSetupNotice from './AccountSetupNotice';
|
||||
|
||||
/** 당일 매매 통계 인터페이스 (Dashboard의 useMemo에서 계산) */
|
||||
interface TodayTradeStats {
|
||||
@@ -38,6 +39,10 @@ interface PortfolioSummaryProps {
|
||||
error: string | null | undefined;
|
||||
/** 당일 매매 통계 */
|
||||
todayTradeStats?: TodayTradeStats | null;
|
||||
/** true면 KIS 계좌 미등록/미검증 — error/스켈레톤 대신 계좌 설정 안내 표시 */
|
||||
accountSetupRequired?: boolean;
|
||||
/** 계좌 미등록/미검증 안내 문구 (accountSetupRequired=true일 때만 사용) */
|
||||
accountSetupMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,7 +55,11 @@ interface PortfolioSummaryProps {
|
||||
* 에러 상태: 빨간 경고 배너 표시
|
||||
* 로딩 상태: 스켈레톤 애니메이션 (animate-pulse)
|
||||
*/
|
||||
export const PortfolioSummary = ({ summary, error, todayTradeStats }: PortfolioSummaryProps) => {
|
||||
export const PortfolioSummary = ({ summary, error, todayTradeStats, accountSetupRequired, accountSetupMessage }: PortfolioSummaryProps) => {
|
||||
if (accountSetupRequired) {
|
||||
return <AccountSetupNotice message={accountSetupMessage} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-red-400 text-xs px-3 py-1.5 bg-[#1a0808] border border-red-900/40 rounded mb-3">
|
||||
|
||||
@@ -32,6 +32,8 @@ interface RecommendationItemProps {
|
||||
isPending: boolean
|
||||
livePrices: Record<string, number> | undefined
|
||||
onBuy: (stockCode: string, stockName: string) => Promise<void>
|
||||
/** true면 매수 버튼 비활성화 (예: 사용자 모드 확정 전) */
|
||||
buyDisabled?: boolean
|
||||
}
|
||||
|
||||
const RecommendationItem = React.memo(({
|
||||
@@ -45,6 +47,7 @@ const RecommendationItem = React.memo(({
|
||||
isPending,
|
||||
livePrices,
|
||||
onBuy,
|
||||
buyDisabled,
|
||||
}: RecommendationItemProps) => {
|
||||
const changeRate = stock.change_rate || 0
|
||||
const isSelected = selectedStock === stock.stock_code
|
||||
@@ -53,13 +56,15 @@ const RecommendationItem = React.memo(({
|
||||
|
||||
const handleBuyClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
if (buying) return
|
||||
if (buying || buyDisabled) return
|
||||
if (!window.confirm(`${stock.stock_name} 시장가 매수할까요?\n(수량: 총자산 × 종목당 최대비중으로 자동 산출)`)) return
|
||||
setBuying(true)
|
||||
try {
|
||||
await onBuy(stock.stock_code, stock.stock_name)
|
||||
} catch (error: unknown) {
|
||||
const err = error as any
|
||||
// 계좌 미등록/미검증(400/423)은 Dashboard 상단 인라인 안내로 처리 — alert 생략
|
||||
if (err?.isAccountSetupRequired) return
|
||||
alert(`매수 실패: ${err.response?.data?.detail || err.message}`)
|
||||
} finally {
|
||||
setBuying(false)
|
||||
@@ -135,7 +140,7 @@ const RecommendationItem = React.memo(({
|
||||
<div className="px-3 pb-1.5 pl-8">
|
||||
<button
|
||||
onClick={handleBuyClick}
|
||||
disabled={buying}
|
||||
disabled={buying || buyDisabled}
|
||||
className="w-full py-0.5 text-[9px] font-mono text-[#3a5570] hover:text-[#e84444] border border-[#152035] hover:border-[#e84444]/40 rounded-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{buying ? '매수 주문 중...' : '매수 (시장가)'}
|
||||
@@ -170,6 +175,8 @@ interface RecommendationPanelProps {
|
||||
livePrices: Record<string, number> | undefined
|
||||
/** 수동 매수 콜백 */
|
||||
onBuy: (stockCode: string, stockName: string) => Promise<void>
|
||||
/** true면 매수 버튼 비활성화 (예: 사용자 모드 확정 전) */
|
||||
buyDisabled?: boolean
|
||||
}
|
||||
|
||||
const RecommendationPanel = React.memo(({
|
||||
@@ -183,6 +190,7 @@ const RecommendationPanel = React.memo(({
|
||||
pendingBuyStockCodes,
|
||||
livePrices,
|
||||
onBuy,
|
||||
buyDisabled,
|
||||
}: RecommendationPanelProps) => {
|
||||
return (
|
||||
<div className="bg-[#0c1522] border border-[#152035] rounded overflow-hidden flex flex-col h-1/2">
|
||||
@@ -242,6 +250,7 @@ const RecommendationPanel = React.memo(({
|
||||
isPending={isPending}
|
||||
livePrices={livePrices}
|
||||
onBuy={onBuy}
|
||||
buyDisabled={buyDisabled}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
63
frontend/src/hooks/useAccountSetupStatus.ts
Normal file
63
frontend/src/hooks/useAccountSetupStatus.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* useAccountSetupStatus.ts — v2(로그인 세션) 사용자의 KIS 계좌 등록·검증 상태
|
||||
*
|
||||
* 배경:
|
||||
* - `/portfolio/v2`, `/portfolio/v2/summary`(GET)는 KIS 계좌가 없어도 예외 없이
|
||||
* 빈 값(0 잔고)을 반환한다(계좌 연결은 `get_user_context`를 쓰는 매수/매도/동기화
|
||||
* 경로에서만 400/423으로 강제됨). 그대로 두면 "0 자산 대시보드"만 보이고
|
||||
* 원인(계좌 미등록)을 알 수 없으므로, 기존 `/account/credentials` 조회로
|
||||
* 선제적으로 상태를 판단해 화면에 안내한다.
|
||||
* - legacy(API 키) 세션에서는 항상 required=false (계좌 개념이 전역 싱글톤이라 무관).
|
||||
* - mode 미확정(undefined, `useTenantMode()` M5 참조) 동안은 v2인지 legacy인지 알 수
|
||||
* 없으므로 required=false 단정 대신 isLoading=true로 보류한다.
|
||||
*
|
||||
* `queryKey: ['kis-credentials']`는 AccountSettings.tsx와 동일 — 캐시 공유.
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { AxiosError } from 'axios'
|
||||
import { getKisCredentials, KisCredentials } from '../lib/api'
|
||||
import { useTenantMode } from '../lib/tenant'
|
||||
|
||||
export interface AccountSetupStatus {
|
||||
/** true면 계좌 미등록/미검증 — 매수·매도·포트폴리오 화면에 안내 필요 */
|
||||
required: boolean
|
||||
message?: string
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export const useAccountSetupStatus = (): AccountSetupStatus => {
|
||||
const { mode } = useTenantMode()
|
||||
|
||||
const { data, isLoading } = useQuery<KisCredentials | null>({
|
||||
queryKey: ['kis-credentials'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return (await getKisCredentials()).data
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response?.status === 404) return null
|
||||
throw err
|
||||
}
|
||||
},
|
||||
enabled: mode === 'v2',
|
||||
staleTime: 10_000,
|
||||
})
|
||||
|
||||
if (mode === undefined) return { required: false, isLoading: true }
|
||||
if (mode !== 'v2') return { required: false, isLoading: false }
|
||||
if (isLoading) return { required: false, isLoading: true }
|
||||
if (!data) {
|
||||
return {
|
||||
required: true,
|
||||
message: '등록된 KIS 계좌가 없습니다. 내 계좌 설정에서 계좌를 등록하세요.',
|
||||
isLoading: false,
|
||||
}
|
||||
}
|
||||
if (!data.is_enabled) {
|
||||
return {
|
||||
required: true,
|
||||
message: 'KIS 계좌가 아직 검증되지 않았습니다. 내 계좌 설정에서 연결 테스트를 진행하세요.',
|
||||
isLoading: false,
|
||||
}
|
||||
}
|
||||
return { required: false, isLoading: false }
|
||||
}
|
||||
@@ -2,26 +2,44 @@
|
||||
* usePortfolioData.ts — 포트폴리오 데이터 관리 커스텀 훅
|
||||
*
|
||||
* 역할:
|
||||
* - /portfolio/combined API를 통해 보유 종목(holdings)과 요약(summary) 동시 조회
|
||||
* - legacy(API 키 세션): /portfolio/combined API로 보유 종목(holdings)과 요약(summary) 동시 조회
|
||||
* - v2(로그인 세션, 멀티테넌트): /portfolio/v2 + /portfolio/v2/summary를 병렬 조회해 동일 형태로 결합
|
||||
* (v2에는 combined 엔드포인트가 없으므로 프론트에서 결합)
|
||||
* - 30초 간격 자동 폴링으로 장중 포트폴리오 변동 감지
|
||||
* - invalidatePortfolio()로 매매 체결 시 즉시 데이터 갱신 트리거
|
||||
*
|
||||
* 데이터 흐름:
|
||||
* API /portfolio/combined → { holdings: Position[], summary: PortfolioSummary }
|
||||
* → holdings에서 quantity > 0 필터 → portfolio(보유종목), portfolioSummary(요약)
|
||||
* v2/legacy 분기는 `useTenantMode()`(lib/tenant.ts)로 판단하며, 이 훅의 시그니처·반환값은
|
||||
* 기존 그대로 유지한다(호출부인 Dashboard.tsx 수정 최소화). 계좌 미등록·미검증(400) 응답은
|
||||
* 예외를 던지는 대신 `accountSetupRequired`/`accountSetupMessage`로 노출해 화면에서
|
||||
* 빈 상태 UI를 그릴 수 있게 한다.
|
||||
*
|
||||
* @returns portfolio, portfolioSummary, invalidatePortfolio 등
|
||||
* 리뷰 M5: `mode`가 아직 확정되지 않은 동안(undefined)은 쿼리를 비활성화한다 — v2/legacy
|
||||
* 확정 전에 legacy `/portfolio/combined`를 먼저 호출해 세션 사용자에게 owner 데이터가
|
||||
* 노출되는 것을 방지한다.
|
||||
*
|
||||
* @returns portfolio, portfolioSummary, invalidatePortfolio, accountSetupRequired 등
|
||||
*/
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../lib/api'
|
||||
import { AxiosError } from 'axios'
|
||||
import api, { getPortfolioV2, getPortfolioSummaryV2 } from '../lib/api'
|
||||
import { Position, PortfolioSummary } from '../types/trading'
|
||||
import { useTenantMode } from '../lib/tenant'
|
||||
|
||||
/** 포트폴리오 통합 데이터 인터페이스 */
|
||||
export interface PortfolioCombinedData {
|
||||
/** 보유 종목 배열 (quantity > 0 필터 적용) */
|
||||
holdings: Position[]
|
||||
/** 포트폴리오 요약 (총자산, 예수금, 손익 등) */
|
||||
summary: PortfolioSummary
|
||||
summary: PortfolioSummary | null
|
||||
/** true면 계좌 미등록/미검증 — holdings/summary는 비어있는 상태로 취급 */
|
||||
accountSetupRequired: boolean
|
||||
/** 계좌 미등록/미검증 시 백엔드 detail 메시지 */
|
||||
accountSetupMessage?: string
|
||||
}
|
||||
|
||||
const isAccountSetupError = (err: unknown): err is AxiosError<{ detail?: string }> => {
|
||||
const status = (err as AxiosError)?.response?.status
|
||||
return status === 400 || status === 423
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,6 +52,7 @@ export interface PortfolioCombinedData {
|
||||
*/
|
||||
export const usePortfolioData = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { mode } = useTenantMode()
|
||||
|
||||
const {
|
||||
data: portfolioCombined,
|
||||
@@ -41,8 +60,33 @@ export const usePortfolioData = () => {
|
||||
isLoading: isPortfolioLoading,
|
||||
refetch: refetchPortfolio,
|
||||
} = useQuery({
|
||||
queryKey: ['portfolio-combined'],
|
||||
queryFn: async () => {
|
||||
// mode별로 쿼리키를 분리해 v2/legacy 전환 시 이전 캐시가 섞이지 않도록 함
|
||||
queryKey: ['portfolio-combined', mode],
|
||||
queryFn: async (): Promise<PortfolioCombinedData> => {
|
||||
if (mode === 'v2') {
|
||||
try {
|
||||
const [holdingsRes, summaryRes] = await Promise.all([
|
||||
getPortfolioV2(),
|
||||
getPortfolioSummaryV2(),
|
||||
])
|
||||
return {
|
||||
holdings: (holdingsRes.data as Position[]).filter((item) => item.quantity > 0),
|
||||
summary: summaryRes.data as PortfolioSummary,
|
||||
accountSetupRequired: false,
|
||||
}
|
||||
} catch (err) {
|
||||
if (isAccountSetupError(err)) {
|
||||
return {
|
||||
holdings: [],
|
||||
summary: null,
|
||||
accountSetupRequired: true,
|
||||
accountSetupMessage: err.response?.data?.detail,
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const data = (await api.get('/portfolio/combined')).data
|
||||
return {
|
||||
// quantity > 0인 종목만 보유 종목으로 처리 (매도 완료 종목 제외)
|
||||
@@ -50,8 +94,10 @@ export const usePortfolioData = () => {
|
||||
(item) => item.quantity > 0
|
||||
),
|
||||
summary: data.summary as PortfolioSummary,
|
||||
accountSetupRequired: false,
|
||||
}
|
||||
},
|
||||
enabled: mode !== undefined,
|
||||
staleTime: 30000, // 30초 후 stale → 자동 리페치 허용
|
||||
refetchInterval: 30000, // 장중 30초 폴링 (매매 감지 보조)
|
||||
refetchOnWindowFocus: true, // 탭 전환 시 즉시 갱신
|
||||
@@ -60,7 +106,9 @@ export const usePortfolioData = () => {
|
||||
/** 보유 종목 배열 */
|
||||
const portfolio = portfolioCombined?.holdings
|
||||
/** 포트폴리오 요약 데이터 */
|
||||
const portfolioSummary = portfolioCombined?.summary
|
||||
const portfolioSummary = portfolioCombined?.summary ?? undefined
|
||||
const accountSetupRequired = portfolioCombined?.accountSetupRequired ?? false
|
||||
const accountSetupMessage = portfolioCombined?.accountSetupMessage
|
||||
|
||||
/**
|
||||
* 포트폴리오 캐시 무효화 — 매매 체결 감지 시 즉시 호출
|
||||
@@ -78,5 +126,7 @@ export const usePortfolioData = () => {
|
||||
portfolioSummaryError,
|
||||
refetchPortfolio,
|
||||
invalidatePortfolio,
|
||||
accountSetupRequired,
|
||||
accountSetupMessage,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,51 @@
|
||||
/**
|
||||
* useTradeActions.ts — 수동 매수/매도 액션 훅
|
||||
*
|
||||
* v2/legacy 분기:
|
||||
* - v2(로그인 세션): `/portfolio/v2/buy`, `/portfolio/v2/sell`
|
||||
* - legacy(API 키 세션, MULTI_TENANT_ENABLED=false 포함): 기존 `/portfolio/buy`, `/portfolio/sell`
|
||||
* 훅 시그니처(handleBuy/handleSell 파라미터·반환 타입)는 그대로 유지하고 내부 구현만
|
||||
* `useTenantMode()`로 분기한다 — Dashboard.tsx 등 호출부 수정 없이 자동 전환.
|
||||
*
|
||||
* 계좌 미등록/미검증(400/423) 에러는 `err.isAccountSetupRequired = true`로 표시해
|
||||
* 호출부가 alert 대신 인라인 안내(AccountSetupNotice)를 띄울 수 있게 한다.
|
||||
*
|
||||
* 리뷰 M5/M6:
|
||||
* - `mode`가 아직 확정되지 않은 동안(undefined)은 v2/legacy 어느 쪽으로도 주문을 내지
|
||||
* 않도록 매수/매도/미체결정리 모두 즉시 거부한다(`isReady=false`). 호출부는
|
||||
* `isReady`로 버튼을 disabled 처리해야 한다.
|
||||
* - `resetPendingTrades`는 legacy(`DELETE /trades/pending`)가 사용자 무관 전체 삭제이므로
|
||||
* v2 모드에서는 사용자 스코프 `DELETE /trades/v2/pending`으로 분기한다.
|
||||
*/
|
||||
import { useCallback } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../lib/api'
|
||||
import { AxiosError } from 'axios'
|
||||
import api, { buyStockV2, sellStockV2, resetPendingTradesV2 } from '../lib/api'
|
||||
import { useTenantMode } from '../lib/tenant'
|
||||
|
||||
/** 계좌 미등록/미검증(400/423) 여부를 표시하는 확장 에러 타입 */
|
||||
export type TradeActionError = AxiosError<{ detail?: string }> & { isAccountSetupRequired?: boolean }
|
||||
|
||||
const markAccountSetupError = (error: unknown): TradeActionError => {
|
||||
const err = error as TradeActionError
|
||||
const status = err?.response?.status
|
||||
if (status === 400 || status === 423) {
|
||||
err.isAccountSetupRequired = true
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
/** mode 미확정 상태에서 호출됐을 때 던지는 에러 — 계좌 설정 안내가 아니라 "아직 준비 안 됨" */
|
||||
class TenantModeNotReadyError extends Error {
|
||||
constructor() {
|
||||
super('사용자 모드를 확인하는 중입니다. 잠시 후 다시 시도하세요.')
|
||||
}
|
||||
}
|
||||
|
||||
export const useTradeActions = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { mode } = useTenantMode()
|
||||
const isReady = mode !== undefined
|
||||
|
||||
const handleSell = useCallback(
|
||||
async (
|
||||
@@ -11,14 +53,16 @@ export const useTradeActions = () => {
|
||||
stockName: string,
|
||||
quantity: number
|
||||
): Promise<void> => {
|
||||
if (mode === undefined) throw new TenantModeNotReadyError()
|
||||
try {
|
||||
// KIS 모의서버 지연 시 주문 1건에 30~60초 걸릴 수 있음 — 기본 15초로 끊으면
|
||||
// 서버는 체결하는데 화면은 실패로 보여 중복 주문을 유발함
|
||||
await api.post('/portfolio/sell', {
|
||||
stock_code: stockCode,
|
||||
quantity,
|
||||
price: null,
|
||||
}, { timeout: 90000 })
|
||||
const body = { stock_code: stockCode, quantity, price: null }
|
||||
if (mode === 'v2') {
|
||||
await sellStockV2(body)
|
||||
} else {
|
||||
await api.post('/portfolio/sell', body, { timeout: 90000 })
|
||||
}
|
||||
|
||||
// Invalidate queries to refresh portfolio
|
||||
queryClient.invalidateQueries({
|
||||
@@ -29,44 +73,50 @@ export const useTradeActions = () => {
|
||||
})
|
||||
|
||||
console.log(`✅ ${stockName} sell order completed`)
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
const err = markAccountSetupError(error)
|
||||
console.error(
|
||||
`❌ Sell order failed for ${stockName}:`,
|
||||
error.response?.data?.detail || error.message
|
||||
err.response?.data?.detail || err.message
|
||||
)
|
||||
throw error
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[queryClient]
|
||||
[queryClient, mode]
|
||||
)
|
||||
|
||||
/** 추천 종목 수동 매수 (시장가, 수량은 서버가 예산 기반 산출) */
|
||||
const handleBuy = useCallback(
|
||||
async (stockCode: string, stockName: string): Promise<void> => {
|
||||
if (mode === undefined) throw new TenantModeNotReadyError()
|
||||
try {
|
||||
await api.post('/portfolio/buy', {
|
||||
stock_code: stockCode,
|
||||
stock_name: stockName,
|
||||
quantity: null,
|
||||
price: null,
|
||||
}, { timeout: 90000 })
|
||||
const body = { stock_code: stockCode, stock_name: stockName, quantity: null, price: null }
|
||||
if (mode === 'v2') {
|
||||
await buyStockV2(body)
|
||||
} else {
|
||||
await api.post('/portfolio/buy', body, { timeout: 90000 })
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio-combined'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['trade-history'] })
|
||||
console.log(`✅ ${stockName} buy order completed`)
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
const err = markAccountSetupError(error)
|
||||
console.error(
|
||||
`❌ Buy order failed for ${stockName}:`,
|
||||
error.response?.data?.detail || error.message
|
||||
err.response?.data?.detail || err.message
|
||||
)
|
||||
throw error
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[queryClient]
|
||||
[queryClient, mode]
|
||||
)
|
||||
|
||||
const resetPendingTrades = useCallback(async (): Promise<void> => {
|
||||
if (mode === undefined) throw new TenantModeNotReadyError()
|
||||
try {
|
||||
const response = await api.delete('/trades/pending')
|
||||
const response = mode === 'v2'
|
||||
? await resetPendingTradesV2()
|
||||
: await api.delete('/trades/pending')
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['trade-history'],
|
||||
})
|
||||
@@ -78,7 +128,7 @@ export const useTradeActions = () => {
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}, [queryClient])
|
||||
}, [queryClient, mode])
|
||||
|
||||
const invalidateTradeHistory = useCallback(() => {
|
||||
queryClient.invalidateQueries({
|
||||
@@ -91,5 +141,7 @@ export const useTradeActions = () => {
|
||||
handleBuy,
|
||||
resetPendingTrades,
|
||||
invalidateTradeHistory,
|
||||
/** false면 mode 미확정 — 매수/매도/미체결정리 버튼을 disabled 처리해야 함 */
|
||||
isReady,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,4 +176,76 @@ export const getUsStockPrice = (ticker: string) =>
|
||||
export const getUsStockChart = (ticker: string, period: string = 'D') =>
|
||||
api.get(`/market-data/overseas/chart/${ticker}`, { params: { period } })
|
||||
|
||||
// ── 사용자 스코프 v2 API (멀티테넌트 1단계, D그룹) ─────────────────────────
|
||||
// 응답 스키마는 기존 v1(`/portfolio`, `/trades`)과 동일 필드. 호출자는
|
||||
// `lib/tenant.ts`의 `useTenantMode()`로 v2/legacy 여부를 판단해 골라 쓴다.
|
||||
// 423 = 사용자 자동매매 미활성(계좌 미검증), 400 = KIS 계좌 미등록·미검증
|
||||
// (둘 다 "내 계좌 설정에서 계좌를 등록·검증하세요" 안내 대상).
|
||||
|
||||
export interface ManualTradeRequest {
|
||||
stock_code: string
|
||||
stock_name?: string
|
||||
quantity?: number | null
|
||||
price?: number | null
|
||||
}
|
||||
|
||||
export const getPortfolioV2 = () => api.get('/portfolio/v2')
|
||||
|
||||
export const getPortfolioSummaryV2 = () => api.get('/portfolio/v2/summary')
|
||||
|
||||
export const syncPortfolioV2 = () => api.post('/portfolio/v2/sync')
|
||||
|
||||
export const buyStockV2 = (body: ManualTradeRequest) =>
|
||||
api.post('/portfolio/v2/buy', body, { timeout: 90000 })
|
||||
|
||||
export const sellStockV2 = (body: ManualTradeRequest) =>
|
||||
api.post('/portfolio/v2/sell', body, { timeout: 90000 })
|
||||
|
||||
export const getTradesV2 = (params?: Record<string, unknown>) =>
|
||||
api.get('/trades/v2', { params })
|
||||
|
||||
// M6: legacy `/trades/pending`는 user_id 조건 없이 전체 PENDING/FAILED 이력을 삭제하므로
|
||||
// v2(로그인 세션) 모드에서는 사용자 스코프 삭제 엔드포인트를 별도로 사용한다.
|
||||
export const resetPendingTradesV2 = () => api.delete('/trades/v2/pending')
|
||||
|
||||
// ── 내 계좌 설정 (KIS 자격증명) ─────────────────────────────────────────────────
|
||||
|
||||
export interface KisCredentials {
|
||||
mode: 'virtual' | 'real'
|
||||
app_key_hint: string
|
||||
account_masked: string
|
||||
is_enabled: boolean
|
||||
auto_trade_enabled: boolean
|
||||
last_verified_at: string | null
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface KisCredentialsInput {
|
||||
mode: 'virtual' | 'real'
|
||||
app_key: string
|
||||
app_secret: string
|
||||
account_no: string
|
||||
}
|
||||
|
||||
export interface KisVerifyResult {
|
||||
ok: boolean
|
||||
balance_summary?: Record<string, unknown>
|
||||
verified_at: string
|
||||
}
|
||||
|
||||
export const getKisCredentials = () =>
|
||||
api.get<KisCredentials>('/account/credentials')
|
||||
|
||||
export const putKisCredentials = (body: KisCredentialsInput) =>
|
||||
api.put<KisCredentials>('/account/credentials', body)
|
||||
|
||||
export const deleteKisCredentials = () =>
|
||||
api.delete<void>('/account/credentials')
|
||||
|
||||
export const verifyKisCredentials = () =>
|
||||
api.post<KisVerifyResult>('/account/credentials/verify')
|
||||
|
||||
export const patchAutoTrade = (enabled: boolean) =>
|
||||
api.patch<KisCredentials>('/account/auto-trade', { enabled })
|
||||
|
||||
export default api
|
||||
|
||||
52
frontend/src/lib/tenant.ts
Normal file
52
frontend/src/lib/tenant.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* tenant.ts — 멀티테넌트 v2/legacy 전환 스위치
|
||||
*
|
||||
* 역할:
|
||||
* - `GET /auth/me`로 호출 주체(auth_type)를 확인해 포트폴리오·거래이력·매수/매도가
|
||||
* 사용자 스코프 v2 API(`/portfolio/v2`, `/trades/v2`)를 쓸지, 기존 v1 API를
|
||||
* 쓸지 결정한다.
|
||||
* - auth_type === 'session' (로그인 사용자) → v2
|
||||
* - auth_type === 'api_key' (레거시 운영자 API 키, MULTI_TENANT_ENABLED=false 환경 포함)
|
||||
* → legacy(v1). 백엔드도 이 경우 `/portfolio/v2/sync`를 스킵 응답하므로 v1 그대로 사용해야 한다.
|
||||
*
|
||||
* 캐시: staleTime 5분 — 세션 타입은 로그인 상태에서 바뀌지 않으므로 자주 재조회할 필요 없음.
|
||||
*
|
||||
* 리뷰 M5 대응: `/auth/me` 응답이 오기 전에 'legacy'를 반환하면 세션(로그인) 사용자에게
|
||||
* 잠깐 owner(legacy) 데이터가 노출되고 그 사이 매수/매도가 legacy 계좌로 나갈 수 있다.
|
||||
* 따라서 결정 전(isLoading) 및 조회 실패(401 등) 시에는 `undefined`를 반환해 호출부가
|
||||
* 데이터 조회·매매 액션을 모두 보류하도록 한다. 401은 기존 axios 응답 인터셉터가
|
||||
* `api_key`를 지우고 새로고침(→ 로그인 화면)하므로 별도 처리가 필요 없다.
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from './api'
|
||||
|
||||
export interface AuthMe {
|
||||
username: string
|
||||
auth_type: 'session' | 'api_key'
|
||||
is_admin: boolean
|
||||
}
|
||||
|
||||
/** 'v2' | 'legacy' 확정 전(로딩 중·조회 실패)에는 undefined */
|
||||
export type TenantMode = 'v2' | 'legacy' | undefined
|
||||
|
||||
/**
|
||||
* useTenantMode — 현재 호출 주체의 v2/legacy 모드를 반환
|
||||
*
|
||||
* `/auth/me` 조회가 끝나기 전(isLoading)이거나 실패(error)한 동안에는 모드를
|
||||
* 확정할 수 없으므로 `undefined`를 반환한다. 호출부는 `mode !== undefined`를
|
||||
* react-query `enabled` 조건과 매수/매도 버튼 disabled 조건으로 사용해야 한다.
|
||||
*/
|
||||
export const useTenantMode = () => {
|
||||
const { data: me, isLoading, error } = useQuery<AuthMe>({
|
||||
queryKey: ['auth-me'],
|
||||
queryFn: async () => (await api.get('/auth/me')).data,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
const mode: TenantMode = isLoading || error || !me
|
||||
? undefined
|
||||
: me.auth_type === 'session' ? 'v2' : 'legacy'
|
||||
|
||||
return { mode, me, isLoading, error }
|
||||
}
|
||||
525
frontend/src/pages/AccountSettings.tsx
Normal file
525
frontend/src/pages/AccountSettings.tsx
Normal file
@@ -0,0 +1,525 @@
|
||||
/**
|
||||
* AccountSettings.tsx — 내 계좌 설정 페이지
|
||||
*
|
||||
* 역할:
|
||||
* - 사용자별 KIS(한국투자증권) 자격증명 등록/교체/삭제
|
||||
* - 연결 테스트(KIS 인증 확인) 및 자동매매 활성화 토글
|
||||
*
|
||||
* 데이터 흐름:
|
||||
* - react-query: /account/credentials (조회), /auth/me (세션 타입 확인)
|
||||
* - mutation: PUT(등록/교체), DELETE(삭제), POST verify(연결 테스트), PATCH auto-trade(토글)
|
||||
*
|
||||
* 주의:
|
||||
* - 실전(real) 계좌 등록 시 확인 모달 필수 (실제 주문 발생)
|
||||
* - 자격증명은 마스킹된 형태로만 표시 (평문 재노출 없음)
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { AxiosError } from 'axios'
|
||||
import {
|
||||
KeyRound,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Trash2,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Info,
|
||||
} from 'lucide-react'
|
||||
import api, {
|
||||
getKisCredentials,
|
||||
putKisCredentials,
|
||||
deleteKisCredentials,
|
||||
verifyKisCredentials,
|
||||
patchAutoTrade,
|
||||
KisCredentials,
|
||||
KisCredentialsInput,
|
||||
} from '../lib/api'
|
||||
|
||||
/** 계좌번호 표시용 정규화: 숫자만 남긴 뒤 8-2 형태로 하이픈 삽입 */
|
||||
const formatAccountInput = (raw: string) => {
|
||||
const digits = raw.replace(/[^0-9]/g, '').slice(0, 10)
|
||||
if (digits.length <= 8) return digits
|
||||
return `${digits.slice(0, 8)}-${digits.slice(8)}`
|
||||
}
|
||||
|
||||
interface AuthMe {
|
||||
username: string
|
||||
auth_type: 'session' | 'api_key'
|
||||
is_admin: boolean
|
||||
}
|
||||
|
||||
const AccountSettings = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [showAppKey, setShowAppKey] = useState(false)
|
||||
const [showAppSecret, setShowAppSecret] = useState(false)
|
||||
const [mode, setMode] = useState<'virtual' | 'real'>('virtual')
|
||||
const [appKey, setAppKey] = useState('')
|
||||
const [appSecret, setAppSecret] = useState('')
|
||||
const [accountNo, setAccountNo] = useState('')
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const [showRealConfirm, setShowRealConfirm] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [verifyBanner, setVerifyBanner] = useState<
|
||||
{ type: 'success' | 'error'; msg: string; balance?: Record<string, unknown> } | null
|
||||
>(null)
|
||||
const [cooldownSec, setCooldownSec] = useState(0)
|
||||
|
||||
// 60초 연결 테스트 쿨다운 카운트다운
|
||||
useEffect(() => {
|
||||
if (cooldownSec <= 0) return
|
||||
const t = setInterval(() => setCooldownSec((s) => Math.max(0, s - 1)), 1000)
|
||||
return () => clearInterval(t)
|
||||
}, [cooldownSec])
|
||||
|
||||
const { data: me } = useQuery<AuthMe>({
|
||||
queryKey: ['auth-me'],
|
||||
queryFn: async () => (await api.get('/auth/me')).data,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const {
|
||||
data: credentials,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery<KisCredentials | null>({
|
||||
queryKey: ['kis-credentials'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return (await getKisCredentials()).data
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response?.status === 404) return null
|
||||
throw err
|
||||
}
|
||||
},
|
||||
staleTime: 10_000,
|
||||
})
|
||||
|
||||
const isRegistered = !!credentials
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (body: KisCredentialsInput) => putKisCredentials(body),
|
||||
onSuccess: (res) => {
|
||||
queryClient.setQueryData(['kis-credentials'], res.data)
|
||||
setAppKey('')
|
||||
setAppSecret('')
|
||||
setAccountNo('')
|
||||
setFormError(null)
|
||||
setVerifyBanner(null)
|
||||
},
|
||||
onError: (err: AxiosError<any>) => {
|
||||
const status = err.response?.status
|
||||
if (status === 409) {
|
||||
setFormError('이미 다른 사용자가 등록한 앱키입니다. 다른 앱키를 사용하세요.')
|
||||
} else if (status === 422) {
|
||||
setFormError(err.response?.data?.detail ?? '입력 형식을 확인하세요 (계좌번호는 10자리 숫자).')
|
||||
} else if (status === 503) {
|
||||
setFormError('서버 암호화 설정이 되어 있지 않습니다. 관리자에게 문의하세요.')
|
||||
} else {
|
||||
setFormError(err.response?.data?.detail ?? '저장에 실패했습니다.')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => deleteKisCredentials(),
|
||||
onSuccess: () => {
|
||||
queryClient.setQueryData(['kis-credentials'], null)
|
||||
setShowDeleteConfirm(false)
|
||||
setVerifyBanner(null)
|
||||
},
|
||||
})
|
||||
|
||||
const verifyMutation = useMutation({
|
||||
mutationFn: () => verifyKisCredentials(),
|
||||
onSuccess: (res) => {
|
||||
setVerifyBanner({ type: 'success', msg: '연결 확인 성공', balance: res.data.balance_summary })
|
||||
queryClient.invalidateQueries({ queryKey: ['kis-credentials'] })
|
||||
},
|
||||
onError: (err: AxiosError<any>) => {
|
||||
if (err.response?.status === 429) {
|
||||
const detail = err.response?.data?.detail ?? '잠시 후 다시 시도하세요.'
|
||||
setVerifyBanner({ type: 'error', msg: detail })
|
||||
setCooldownSec(60)
|
||||
} else {
|
||||
setVerifyBanner({
|
||||
type: 'error',
|
||||
msg: err.response?.data?.detail ?? 'KIS 인증에 실패했습니다.',
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const autoTradeMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => patchAutoTrade(enabled),
|
||||
onSuccess: (res) => {
|
||||
queryClient.setQueryData(['kis-credentials'], res.data)
|
||||
},
|
||||
onError: (err: AxiosError<any>) => {
|
||||
setFormError(err.response?.data?.detail ?? '자동매매 설정 변경에 실패했습니다.')
|
||||
},
|
||||
})
|
||||
|
||||
const submitCredentials = () => {
|
||||
setFormError(null)
|
||||
const digits = accountNo.replace(/[^0-9]/g, '')
|
||||
if (!appKey.trim() || !appSecret.trim() || digits.length !== 10) {
|
||||
setFormError('앱키·앱시크릿·계좌번호(10자리)를 모두 입력하세요.')
|
||||
return
|
||||
}
|
||||
const body: KisCredentialsInput = { mode, app_key: appKey.trim(), app_secret: appSecret.trim(), account_no: accountNo }
|
||||
if (mode === 'real') {
|
||||
setShowRealConfirm(true)
|
||||
return
|
||||
}
|
||||
saveMutation.mutate(body)
|
||||
}
|
||||
|
||||
const confirmRealSubmit = () => {
|
||||
setShowRealConfirm(false)
|
||||
saveMutation.mutate({ mode, app_key: appKey.trim(), app_secret: appSecret.trim(), account_no: accountNo })
|
||||
}
|
||||
|
||||
const isApiKeySession = me?.auth_type === 'api_key'
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-[#0f1115] px-6 py-8">
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-100 flex items-center gap-2">
|
||||
<KeyRound size={22} className="text-blue-400" />
|
||||
내 계좌 설정
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
자동매매에 사용할 한국투자증권(KIS) 계좌 자격증명을 등록·관리합니다.
|
||||
</p>
|
||||
{!isApiKeySession && me && (
|
||||
<p className="text-xs text-blue-400/80 mt-1">
|
||||
대시보드·포트폴리오·매매는 여기 등록한 내 계좌 기준으로 표시됩니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isApiKeySession && (
|
||||
<span className="text-xs px-3 py-1.5 rounded-full border border-yellow-500/40 bg-yellow-500/10 text-yellow-400">
|
||||
운영자(API 키) 세션 — legacy 계좌
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 상태 카드 */}
|
||||
<div className="bg-[#161b22] border border-gray-800 rounded-xl p-5">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-gray-500 text-sm py-4">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
불러오는 중...
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="flex items-center gap-2 text-red-400 text-sm py-4" role="alert">
|
||||
<XCircle size={16} />
|
||||
상태를 불러오지 못했습니다.
|
||||
</div>
|
||||
) : !isRegistered ? (
|
||||
<div className="flex items-center gap-3 text-gray-400 text-sm py-4">
|
||||
<Info size={18} className="text-gray-500 shrink-0" />
|
||||
등록된 계좌가 없습니다. 아래 폼에서 계좌를 등록하세요.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`text-xs font-semibold px-2.5 py-1 rounded-full border ${
|
||||
credentials!.mode === 'real'
|
||||
? 'bg-red-500/15 text-red-400 border-red-500/50'
|
||||
: 'bg-blue-500/15 text-blue-400 border-blue-500/40'
|
||||
}`}
|
||||
>
|
||||
{credentials!.mode === 'real' ? '실전투자' : '모의투자'}
|
||||
</span>
|
||||
<span className="text-sm text-gray-300 font-mono">
|
||||
{credentials!.app_key_hint}…
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 font-mono">
|
||||
{credentials!.account_masked}
|
||||
</span>
|
||||
{credentials!.is_enabled ? (
|
||||
<span className="flex items-center gap-1 text-xs text-green-400">
|
||||
<ShieldCheck size={14} /> 검증됨
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs text-yellow-400">
|
||||
<ShieldAlert size={14} /> 미검증
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="flex items-center gap-1.5 text-xs text-red-400 border border-red-500/40 rounded-lg px-3 py-1.5 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-600">
|
||||
마지막 검증:{' '}
|
||||
{credentials!.last_verified_at
|
||||
? new Date(credentials!.last_verified_at).toLocaleString('ko-KR')
|
||||
: '없음'}
|
||||
</div>
|
||||
|
||||
{/* 연결 테스트 */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
onClick={() => verifyMutation.mutate()}
|
||||
disabled={verifyMutation.isPending || cooldownSec > 0}
|
||||
className="flex items-center gap-2 text-sm px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
|
||||
>
|
||||
{verifyMutation.isPending && <Loader2 size={14} className="animate-spin" />}
|
||||
연결 테스트
|
||||
{cooldownSec > 0 && ` (${cooldownSec}초 후 가능)`}
|
||||
</button>
|
||||
|
||||
{/* 자동매매 토글 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => autoTradeMutation.mutate(!credentials!.auto_trade_enabled)}
|
||||
disabled={!credentials!.is_enabled || autoTradeMutation.isPending}
|
||||
title={!credentials!.is_enabled ? '연결 테스트를 먼저 통과해야 합니다.' : undefined}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors shrink-0 disabled:opacity-30 disabled:cursor-not-allowed ${
|
||||
credentials!.auto_trade_enabled ? 'bg-blue-500' : 'bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${
|
||||
credentials!.auto_trade_enabled ? 'translate-x-5' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-sm text-gray-300">자동매매</span>
|
||||
{!credentials!.is_enabled && (
|
||||
<span className="text-xs text-gray-600">(연결 테스트 필요)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{verifyBanner && (
|
||||
<div
|
||||
role="alert"
|
||||
className={`flex items-start gap-2 text-sm px-3 py-2.5 rounded-lg border ${
|
||||
verifyBanner.type === 'success'
|
||||
? 'bg-green-500/10 border-green-500/30 text-green-400'
|
||||
: 'bg-red-500/10 border-red-500/30 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{verifyBanner.type === 'success' ? (
|
||||
<CheckCircle2 size={16} className="mt-0.5 shrink-0" />
|
||||
) : (
|
||||
<XCircle size={16} className="mt-0.5 shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<p>{verifyBanner.msg}</p>
|
||||
{verifyBanner.balance && (
|
||||
<pre className="mt-1.5 text-xs text-gray-400 whitespace-pre-wrap font-mono">
|
||||
{JSON.stringify(verifyBanner.balance, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 등록/교체 폼 */}
|
||||
<div className="bg-[#161b22] border border-gray-800 rounded-xl p-5 space-y-4">
|
||||
<h2 className="text-sm font-semibold text-gray-200">
|
||||
{isRegistered ? '계좌 교체' : '계좌 등록'}
|
||||
</h2>
|
||||
|
||||
{/* 모드 라디오 */}
|
||||
<fieldset>
|
||||
<legend className="text-xs text-gray-500 mb-2">투자 모드</legend>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="kis-mode"
|
||||
value="virtual"
|
||||
checked={mode === 'virtual'}
|
||||
onChange={() => setMode('virtual')}
|
||||
className="accent-blue-500"
|
||||
/>
|
||||
모의투자
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-red-400 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="kis-mode"
|
||||
value="real"
|
||||
checked={mode === 'real'}
|
||||
onChange={() => setMode('real')}
|
||||
className="accent-red-500"
|
||||
/>
|
||||
실전투자
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="app-key" className="block text-xs text-gray-500 mb-1.5">
|
||||
앱키 (App Key)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="app-key"
|
||||
type={showAppKey ? 'text' : 'password'}
|
||||
value={appKey}
|
||||
onChange={(e) => setAppKey(e.target.value)}
|
||||
aria-invalid={!!formError}
|
||||
className="w-full px-3 py-2 pr-9 text-sm bg-gray-900 border border-gray-700 rounded-lg text-gray-100 focus:outline-none focus:border-blue-500"
|
||||
placeholder="PS로 시작하는 앱키"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAppKey((v) => !v)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
|
||||
aria-label={showAppKey ? '앱키 숨기기' : '앱키 표시'}
|
||||
>
|
||||
{showAppKey ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="app-secret" className="block text-xs text-gray-500 mb-1.5">
|
||||
앱시크릿 (App Secret)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="app-secret"
|
||||
type={showAppSecret ? 'text' : 'password'}
|
||||
value={appSecret}
|
||||
onChange={(e) => setAppSecret(e.target.value)}
|
||||
aria-invalid={!!formError}
|
||||
className="w-full px-3 py-2 pr-9 text-sm bg-gray-900 border border-gray-700 rounded-lg text-gray-100 focus:outline-none focus:border-blue-500"
|
||||
placeholder="앱시크릿"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAppSecret((v) => !v)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
|
||||
aria-label={showAppSecret ? '앱시크릿 숨기기' : '앱시크릿 표시'}
|
||||
>
|
||||
{showAppSecret ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="account-no" className="block text-xs text-gray-500 mb-1.5">
|
||||
계좌번호
|
||||
</label>
|
||||
<input
|
||||
id="account-no"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={accountNo}
|
||||
onChange={(e) => setAccountNo(formatAccountInput(e.target.value))}
|
||||
aria-invalid={!!formError}
|
||||
className="w-full sm:w-56 px-3 py-2 text-sm bg-gray-900 border border-gray-700 rounded-lg text-gray-100 font-mono focus:outline-none focus:border-blue-500"
|
||||
placeholder="12345678-01"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<div role="alert" className="text-sm text-red-400 bg-red-500/10 border border-red-500/30 rounded-lg px-3 py-2">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={submitCredentials}
|
||||
disabled={saveMutation.isPending}
|
||||
className="flex items-center gap-2 text-sm px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white rounded-lg transition-colors"
|
||||
>
|
||||
{saveMutation.isPending && <Loader2 size={14} className="animate-spin" />}
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 실전 계좌 확인 모달 */}
|
||||
{showRealConfirm && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-[#161b22] border border-red-500/40 rounded-xl p-6 max-w-sm w-full space-y-4">
|
||||
<div className="flex items-center gap-2 text-red-400">
|
||||
<ShieldAlert size={20} />
|
||||
<h3 className="font-semibold">실전 계좌 등록 확인</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">
|
||||
실전 계좌로 등록하면 자동매매 활성화 시 <strong className="text-red-400">실제 주문</strong>이
|
||||
체결됩니다. 계속하시겠습니까?
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowRealConfirm(false)}
|
||||
className="px-4 py-2 text-sm text-gray-400 hover:text-white border border-gray-700 rounded-lg"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmRealSubmit}
|
||||
className="px-4 py-2 text-sm bg-red-600 hover:bg-red-500 text-white rounded-lg"
|
||||
>
|
||||
실전 계좌로 등록
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-[#161b22] border border-gray-700 rounded-xl p-6 max-w-sm w-full space-y-4">
|
||||
<div className="flex items-center gap-2 text-gray-200">
|
||||
<Trash2 size={18} className="text-red-400" />
|
||||
<h3 className="font-semibold">계좌 삭제 확인</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">
|
||||
등록된 KIS 계좌 자격증명을 삭제합니다. 자동매매가 즉시 비활성화됩니다.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="px-4 py-2 text-sm text-gray-400 hover:text-white border border-gray-700 rounded-lg"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm bg-red-600 hover:bg-red-500 disabled:opacity-40 text-white rounded-lg"
|
||||
>
|
||||
{deleteMutation.isPending && <Loader2 size={14} className="animate-spin" />}
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountSettings
|
||||
@@ -25,13 +25,17 @@ import api from '../lib/api'
|
||||
import { useWebSocket } from '../hooks/useWebSocket'
|
||||
import { usePortfolioData } from '../hooks/usePortfolioData'
|
||||
import { useMarketData } from '../hooks/useMarketData'
|
||||
import { useTradeActions } from '../hooks/useTradeActions'
|
||||
import { useTradeActions, TradeActionError } from '../hooks/useTradeActions'
|
||||
import { useAccountSetupStatus } from '../hooks/useAccountSetupStatus'
|
||||
import { useTenantMode } from '../lib/tenant'
|
||||
import { getTradesV2 } from '../lib/api'
|
||||
import { PortfolioSummary } from '../components/PortfolioSummary'
|
||||
import PortfolioPanel from '../components/PortfolioPanel'
|
||||
import ChartPanel, { AiAnalysisPanel } from '../components/ChartPanel'
|
||||
import OrderbookPanel from '../components/OrderbookPanel'
|
||||
import RecommendationPanel from '../components/RecommendationPanel'
|
||||
import JournalPanel from '../components/JournalPanel'
|
||||
import AccountSetupNotice from '../components/AccountSetupNotice'
|
||||
import { CandleData, Position, TradeHistory } from '../types/trading'
|
||||
import { isUsStock } from '../utils/stockUtils'
|
||||
import './Dashboard.css'
|
||||
@@ -123,8 +127,16 @@ const Dashboard = () => {
|
||||
portfolioSummary,
|
||||
portfolioSummaryError,
|
||||
invalidatePortfolio,
|
||||
accountSetupRequired,
|
||||
accountSetupMessage,
|
||||
} = usePortfolioData()
|
||||
|
||||
// v2(로그인 세션) 사용자의 KIS 계좌 등록/검증 상태 — 매수/매도 시도 전 선제 안내용
|
||||
const accountStatus = useAccountSetupStatus()
|
||||
const { mode: tenantMode } = useTenantMode()
|
||||
// 매수/매도 액션 중 계좌 미등록/미검증(400/423)을 받으면 배너로 노출 (alert 대체)
|
||||
const [tradeAccountNotice, setTradeAccountNotice] = useState<string | null>(null)
|
||||
|
||||
// Market data hook (with throttled live prices and market update handler)
|
||||
const {
|
||||
livePrices,
|
||||
@@ -210,7 +222,42 @@ const Dashboard = () => {
|
||||
}, [processChartData])
|
||||
|
||||
// Trade actions hook
|
||||
const { handleSell, handleBuy, resetPendingTrades } = useTradeActions()
|
||||
// isReady=false(useTenantMode 확정 전)면 매수/매도/미체결정리 버튼을 모두 disabled 처리
|
||||
const { handleSell, handleBuy, resetPendingTrades, isReady: tradeActionsReady } = useTradeActions()
|
||||
|
||||
// 계좌 미등록/미검증(400/423)은 상단 배너로 흡수하고, 그 외 에러는 그대로 전파해
|
||||
// 각 패널의 기존 alert 처리(다른 실패 사유)가 계속 동작하도록 한다.
|
||||
const handleBuyGuarded = useCallback(
|
||||
async (stockCode: string, stockName: string) => {
|
||||
try {
|
||||
await handleBuy(stockCode, stockName)
|
||||
} catch (error: unknown) {
|
||||
const err = error as TradeActionError
|
||||
if (err.isAccountSetupRequired) {
|
||||
setTradeAccountNotice(err.response?.data?.detail || '자동매매가 비활성화 상태입니다. 내 계좌 설정에서 계좌를 등록·검증하세요.')
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[handleBuy]
|
||||
)
|
||||
|
||||
const handleSellGuarded = useCallback(
|
||||
async (stockCode: string, stockName: string, quantity: number) => {
|
||||
try {
|
||||
await handleSell(stockCode, stockName, quantity)
|
||||
} catch (error: unknown) {
|
||||
const err = error as TradeActionError
|
||||
if (err.isAccountSetupRequired) {
|
||||
setTradeAccountNotice(err.response?.data?.detail || '자동매매가 비활성화 상태입니다. 내 계좌 설정에서 계좌를 등록·검증하세요.')
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[handleSell]
|
||||
)
|
||||
|
||||
// Subscribe to market data via WebSocket
|
||||
// reconnect 감지: wsConnected가 false→true 전환 시 강제 재구독
|
||||
@@ -352,10 +399,13 @@ const Dashboard = () => {
|
||||
|
||||
// 거래 이력 - 30초 (자동매매 체결 감지용)
|
||||
const { data: tradeHistory } = useQuery({
|
||||
queryKey: ['trade-history'],
|
||||
queryKey: ['trade-history', tenantMode],
|
||||
queryFn: async () => {
|
||||
const data = (await api.get('/trades', { params: { limit: 50 } }))
|
||||
.data as TradeHistory[]
|
||||
const data = (
|
||||
tenantMode === 'v2'
|
||||
? await getTradesV2({ limit: 50 })
|
||||
: await api.get('/trades', { params: { limit: 50 } })
|
||||
).data as TradeHistory[]
|
||||
|
||||
const seen = new Set<number>()
|
||||
return data
|
||||
@@ -366,6 +416,8 @@ const Dashboard = () => {
|
||||
})
|
||||
.slice(0, 20)
|
||||
},
|
||||
// M5: mode 미확정(undefined) 동안은 v2/legacy 어느 쪽으로도 조회하지 않음
|
||||
enabled: tenantMode !== undefined,
|
||||
staleTime: 0,
|
||||
refetchInterval: isMarketOpen ? 30000 : false,
|
||||
refetchOnWindowFocus: true,
|
||||
@@ -719,7 +771,16 @@ const Dashboard = () => {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<PortfolioSummary summary={liveSummary} error={portfolioSummaryError?.message ?? null} todayTradeStats={todayTradeStats} />
|
||||
{tradeAccountNotice && (
|
||||
<AccountSetupNotice message={tradeAccountNotice} className="mb-2" />
|
||||
)}
|
||||
<PortfolioSummary
|
||||
summary={liveSummary}
|
||||
error={portfolioSummaryError?.message ?? null}
|
||||
todayTradeStats={todayTradeStats}
|
||||
accountSetupRequired={accountSetupRequired || accountStatus.required}
|
||||
accountSetupMessage={accountSetupMessage || accountStatus.message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-12 gap-3 flex-1 min-h-0">
|
||||
@@ -732,7 +793,10 @@ const Dashboard = () => {
|
||||
selectedStock={selectedStock}
|
||||
onSelectStock={handleSelectStock}
|
||||
onRefetchAiAnalysis={stableRefetchAiAnalysis}
|
||||
onSell={handleSell}
|
||||
onSell={handleSellGuarded}
|
||||
sellDisabled={!tradeActionsReady}
|
||||
accountSetupRequired={accountSetupRequired || accountStatus.required}
|
||||
accountSetupMessage={accountSetupMessage || accountStatus.message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -770,12 +834,14 @@ const Dashboard = () => {
|
||||
heldStockCodes={heldStockCodes}
|
||||
pendingBuyStockCodes={pendingBuyStockCodes}
|
||||
livePrices={livePrices}
|
||||
onBuy={handleBuy}
|
||||
onBuy={handleBuyGuarded}
|
||||
buyDisabled={!tradeActionsReady}
|
||||
/>
|
||||
<JournalPanel
|
||||
journals={journals}
|
||||
tradeHistory={tradeHistory}
|
||||
onResetPending={resetPendingTrades}
|
||||
resetDisabled={!tradeActionsReady}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,8 @@ import { format, differenceInMinutes } from 'date-fns'
|
||||
import { ko } from 'date-fns/locale'
|
||||
import { AlertCircle, List, BarChart2 } from 'lucide-react'
|
||||
import HtsPage from '../components/HtsPage'
|
||||
import api from '../lib/api'
|
||||
import api, { getTradesV2 } from '../lib/api'
|
||||
import { useTenantMode } from '../lib/tenant'
|
||||
import { useState, Fragment } from 'react'
|
||||
|
||||
/** DB에서 KST로 저장된 datetime 문자열을 Date 객체로 변환
|
||||
@@ -338,10 +339,18 @@ const TradeHistory = () => {
|
||||
const today = format(new Date(), 'yyyy-MM-dd')
|
||||
const [dateFrom, setDateFrom] = useState<string>(today)
|
||||
const [dateTo, setDateTo] = useState<string>(today)
|
||||
const { mode: tenantMode } = useTenantMode()
|
||||
|
||||
const { data: trades = [], isLoading, isError } = useQuery<Trade[]>({
|
||||
queryKey: ['trade-history'],
|
||||
queryFn: async () => (await api.get('/trades', { params: { limit: 200, days: 30 } })).data,
|
||||
queryKey: ['trade-history', tenantMode],
|
||||
queryFn: async () =>
|
||||
(
|
||||
tenantMode === 'v2'
|
||||
? await getTradesV2({ limit: 200, days: 30 })
|
||||
: await api.get('/trades', { params: { limit: 200, days: 30 } })
|
||||
).data,
|
||||
// M5: mode 미확정(undefined) 동안은 v2/legacy 어느 쪽으로도 조회하지 않음
|
||||
enabled: tenantMode !== undefined,
|
||||
refetchInterval: 10000,
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user