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:
@@ -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