/** * 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 { AxiosError } from 'axios' import api, { buyStockV2, sellStockV2, resetPendingTradesV2 } from '../lib/api' import { useTenantMode } from '../lib/tenant' import { toast } from '../lib/toast' /** 계좌 미등록/미검증(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 ( stockCode: string, stockName: string, quantity: number ): Promise => { if (mode === undefined) throw new TenantModeNotReadyError() try { // KIS 모의서버 지연 시 주문 1건에 30~60초 걸릴 수 있음 — 기본 15초로 끊으면 // 서버는 체결하는데 화면은 실패로 보여 중복 주문을 유발함 const body = { stock_code: stockCode, quantity, price: null } const res = mode === 'v2' ? await sellStockV2(body) : await api.post('/portfolio/sell', body, { timeout: 90000 }) const d = (res as any)?.data ?? {} const pl = typeof d.profit_loss === 'number' ? ` (손익 ${d.profit_loss >= 0 ? '+' : ''}${Math.round(d.profit_loss).toLocaleString('ko-KR')}원, ${(d.profit_loss_rate ?? 0) >= 0 ? '+' : ''}${Number(d.profit_loss_rate ?? 0).toFixed(2)}%)` : '' toast.success(`${d.message || `${stockName} ${quantity}주 매도 주문 완료`}${pl}`) // 낙관적 갱신: 서버 재조회 전에 화면에서 즉시 제거/차감 queryClient.setQueryData(['portfolio-combined', mode], (prev: any) => { if (!prev?.holdings) return prev const holdings = prev.holdings .map((h: any) => (h.stock_code === stockCode ? { ...h, quantity: h.quantity - quantity } : h)) .filter((h: any) => h.quantity > 0) return { ...prev, holdings } }) // Invalidate queries to refresh portfolio queryClient.invalidateQueries({ queryKey: ['portfolio-combined'], }) queryClient.invalidateQueries({ queryKey: ['trade-history'], }) console.log(`✅ ${stockName} sell order completed`) } catch (error: unknown) { const err = markAccountSetupError(error) console.error( `❌ Sell order failed for ${stockName}:`, err.response?.data?.detail || err.message ) if (!err.isAccountSetupRequired) { toast.error(`${stockName} 매도 실패: ${err.response?.data?.detail || err.message}`) } throw err } }, [queryClient, mode] ) /** 추천 종목 수동 매수 (시장가, 수량은 서버가 예산 기반 산출) */ const handleBuy = useCallback( async (stockCode: string, stockName: string): Promise => { if (mode === undefined) throw new TenantModeNotReadyError() try { const body = { stock_code: stockCode, stock_name: stockName, quantity: null, price: null } const res = mode === 'v2' ? await buyStockV2(body) : await api.post('/portfolio/buy', body, { timeout: 90000 }) const d = (res as any)?.data ?? {} toast.success(d.message || `${stockName} 매수 주문 완료`) queryClient.invalidateQueries({ queryKey: ['portfolio-combined'] }) queryClient.invalidateQueries({ queryKey: ['trade-history'] }) console.log(`✅ ${stockName} buy order completed`) } catch (error: unknown) { const err = markAccountSetupError(error) console.error( `❌ Buy order failed for ${stockName}:`, err.response?.data?.detail || err.message ) if (!err.isAccountSetupRequired) { toast.error(`${stockName} 매수 실패: ${err.response?.data?.detail || err.message}`) } throw err } }, [queryClient, mode] ) const resetPendingTrades = useCallback(async (): Promise => { if (mode === undefined) throw new TenantModeNotReadyError() try { const response = mode === 'v2' ? await resetPendingTradesV2() : await api.delete('/trades/pending') queryClient.invalidateQueries({ queryKey: ['trade-history'], }) console.log('✅ Pending orders cleared:', response.data) } catch (error: any) { console.error( '❌ Failed to clear pending orders:', error.response?.data?.detail || error.message ) throw error } }, [queryClient, mode]) const invalidateTradeHistory = useCallback(() => { queryClient.invalidateQueries({ queryKey: ['trade-history'], }) }, [queryClient]) return { handleSell, handleBuy, resetPendingTrades, invalidateTradeHistory, /** false면 mode 미확정 — 매수/매도/미체결정리 버튼을 disabled 처리해야 함 */ isReady, } }