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:
@@ -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]')}
|
||||
|
||||
Reference in New Issue
Block a user