feat: 멀티테넌트 1단계 — 사용자별 KIS 계좌·포트폴리오·수동매매 (MULTI_TENANT_ENABLED 기본 OFF)
설계: docs/multi_tenant_design.md / 검수: docs/multi_tenant_phase1_review.md (High 0, MT=False 배포 가능)
데이터·인프라
- user_kis_credentials(Fernet 암호화, PBKDF2 키 파생, key_version), stock_bans_v2 신설,
portfolio/trade_history/active_trades/trade_journals/trading_sessions 등 user_id nullable 추가
- alembic 0009~0011(idempotent, legacy owner 백필). main.py 기동 시 portfolio.user_id 스키마 검사(H1)
- Jenkinsfile: DB Migration 을 Deploy 앞으로, alembic 이력 없으면 stamp 0008 후 upgrade(N1)
- bootstrap: users 비어 있으면 BOOTSTRAP_ADMIN_* 로 관리자 시드 + .env KIS 자격증명 이관 + 백필(SAVEPOINT)
백엔드
- core/crypto.py, core/tenant_auth.py(get_current_user — 비활성 사용자 401, API 키→legacy owner,
deny_non_owner_session_when_mt), core/dt_utils.py
- services/tenant: context, kis_pool, registry(UserContextRegistry, 락 최소화·복호화 to_thread), risk_factory
- KoreaInvestmentAPI.for_user(사용자별 인스턴스, 토큰캐시 users/{id}/, 시세는 공용 위임)
- trade_executor.safe_place_order_v2(사용자별 락/세마포어, legacy ctx 는 기존 키)
- mdd_manager/loss_tracker _v2 + ban_until tz-aware/naive 경계 정규화(레거시 경로 버그 포함 수정)
- db_worker: payload user_id 옵션 필터, upsert_stock_ban_v2, legacy ActiveTrade 조회 LIMIT 1
- API: /account/credentials(등록·마스킹조회·삭제·연결테스트·자동매매), /auth/admin/tenant/*,
/portfolio/v2(조회·요약·sync·buy·sell), /trades/v2(+pending). PortfolioResponse Optional 수정
프론트
- /account 내 계좌 설정, useTenantMode(세션=v2 / API키=legacy, 미확정 시 쿼리·버튼 비활성),
계좌 미등록 인라인 안내, v2 매수/매도/거래이력 분기
테스트: tests/integration(격리 Postgres, KIS 전면 mock) 20 passed / 1 xfailed(0012 이전 제약)
2단계(스케줄러 사용자화·legacy 라우터 격리) 전까지 MULTI_TENANT_ENABLED=True 전환 금지
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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"}
|
||||
Reference in New Issue
Block a user