feat: 멀티테넌트 1단계 — 사용자별 KIS 계좌·포트폴리오·수동매매 (MULTI_TENANT_ENABLED 기본 OFF)

설계: docs/multi_tenant_design.md / 검수: docs/multi_tenant_phase1_review.md (High 0, MT=False 배포 가능)

데이터·인프라
- user_kis_credentials(Fernet 암호화, PBKDF2 키 파생, key_version), stock_bans_v2 신설,
  portfolio/trade_history/active_trades/trade_journals/trading_sessions 등 user_id nullable 추가
- alembic 0009~0011(idempotent, legacy owner 백필). main.py 기동 시 portfolio.user_id 스키마 검사(H1)
- Jenkinsfile: DB Migration 을 Deploy 앞으로, alembic 이력 없으면 stamp 0008 후 upgrade(N1)
- bootstrap: users 비어 있으면 BOOTSTRAP_ADMIN_* 로 관리자 시드 + .env KIS 자격증명 이관 + 백필(SAVEPOINT)

백엔드
- core/crypto.py, core/tenant_auth.py(get_current_user — 비활성 사용자 401, API 키→legacy owner,
  deny_non_owner_session_when_mt), core/dt_utils.py
- services/tenant: context, kis_pool, registry(UserContextRegistry, 락 최소화·복호화 to_thread), risk_factory
- KoreaInvestmentAPI.for_user(사용자별 인스턴스, 토큰캐시 users/{id}/, 시세는 공용 위임)
- trade_executor.safe_place_order_v2(사용자별 락/세마포어, legacy ctx 는 기존 키)
- mdd_manager/loss_tracker _v2 + ban_until tz-aware/naive 경계 정규화(레거시 경로 버그 포함 수정)
- db_worker: payload user_id 옵션 필터, upsert_stock_ban_v2, legacy ActiveTrade 조회 LIMIT 1
- API: /account/credentials(등록·마스킹조회·삭제·연결테스트·자동매매), /auth/admin/tenant/*,
  /portfolio/v2(조회·요약·sync·buy·sell), /trades/v2(+pending). PortfolioResponse Optional 수정

프론트
- /account 내 계좌 설정, useTenantMode(세션=v2 / API키=legacy, 미확정 시 쿼리·버튼 비활성),
  계좌 미등록 인라인 안내, v2 매수/매도/거래이력 분기

테스트: tests/integration(격리 Postgres, KIS 전면 mock) 20 passed / 1 xfailed(0012 이전 제약)
2단계(스케줄러 사용자화·legacy 라우터 격리) 전까지 MULTI_TENANT_ENABLED=True 전환 금지

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-21 10:26:51 +09:00
parent dc128ae4cf
commit b4a1fc79ab
52 changed files with 5614 additions and 96 deletions

View File

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

View File

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

View File

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