- portfolio_cache_bridge 신설: 매매 엔진 체결 시 Portfolio(db_worker 큐) + 인메모리 캐시 즉시 갱신. DT 매수는 strategy_type='daytrading'으로 태깅되어 보유 종목 패널의 '데이트레이딩' 그룹에 표시되고 스윙 청산 로직(strategy_type='swing' 대상)에서 제외됨. (기존엔 KIS 동기화로 'swing'으로 유입돼 DT 포지션이 안 보이고 스윙 엔진이 같이 관리하는 문제) - DT 매도·스윙 자동 매도도 캐시에서 즉시 제거/차감 + 최근매도 기록(동기화 되살림 방지). - 동기화 시 DT 엔진 보유 종목은 'daytrading'으로 태깅. - 프론트: 수동 매도 성공 시 낙관적 갱신으로 서버 재조회 전 즉시 목록에서 제거. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
167 lines
7.4 KiB
TypeScript
167 lines
7.4 KiB
TypeScript
/**
|
|
* 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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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,
|
|
}
|
|
}
|