diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py index 8644c22..9c69eff 100644 --- a/backend/app/api/endpoints/portfolio.py +++ b/backend/app/api/endpoints/portfolio.py @@ -128,7 +128,10 @@ def _extract_balance_data(balance_data: dict) -> dict: if balance_data and "output2" in balance_data: output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"] + # _ord_psbl_cash: sync_portfolio()가 매수가능조회로 채워 넣는 주문가능현금 + # (당일 매도대금 포함). 없으면 예수금총액(D+2 결제 기준) 폴백. available_cash = _pick_float(output2, [ + "_ord_psbl_cash", "dnca_tot_amt", "d2_depo_amt", "d2_deposit", @@ -212,6 +215,22 @@ class PortfolioCache: self.next_retry_at: datetime | None = None self.last_sync_success: bool = False self.last_trade_id: int | None = None + # 최근 수동 매도 {code: (qty, ts)} — KIS 잔고 반영 지연으로 동기화가 판 종목을 + # 되살리는 것 방지 (5분 TTL) + self.recent_sells: dict[str, tuple[int, datetime]] = {} + + def note_sell(self, stock_code: str, quantity: int) -> None: + self.recent_sells[stock_code] = (quantity, datetime.now()) + + def recently_sold_qty(self, stock_code: str, ttl_sec: int = 300) -> int: + rec = self.recent_sells.get(stock_code) + if not rec: + return 0 + qty, ts = rec + if (datetime.now() - ts).total_seconds() > ttl_sec: + self.recent_sells.pop(stock_code, None) + return 0 + return qty def mark_changed(self): self.change_flag = True @@ -483,6 +502,16 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) logger.warning(f"⚠️ 해외 잔고 조회 실패: {overseas_balance_data}") overseas_balance_data = {} + # 주문가능현금(당일 매도대금 포함) → output2에 주입해 available_cash로 사용 + try: + if balance_data and balance_data.get("rt_cd") == "0" and balance_data.get("output2"): + _buyable = await korea_investment_api.get_buyable_cash() + if _buyable > 0: + _o2 = balance_data["output2"] + (_o2[0] if isinstance(_o2, list) else _o2)["_ord_psbl_cash"] = _buyable + except Exception as _bc_err: + logger.debug(f"주문가능현금 조회 실패 (예수금총액 폴백): {_bc_err}") + # 디버깅: KIS API 전체 응답 로깅 logger.info(f"🔍 KIS API balance response keys: {balance_data.keys() if balance_data else 'None'}") @@ -577,6 +606,14 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) for item in balance_data["output1"]: stock_code = item.get("pdno") + # 최근 5분 내 수동 매도한 수량 이하로만 남아 있으면 KIS 반영 지연으로 판단 + _sold_qty = portfolio_cache.recently_sold_qty(stock_code) + if _sold_qty and int(item.get("hldg_qty", 0)) <= _sold_qty: + logger.info(f"👻 최근 수동 매도 종목 스킵: {stock_code} (KIS 잔고 반영 대기)") + if stock_code in existing_all: + await db.delete(existing_all[stock_code]) + continue + if stock_code in ghost_candidates: logger.info(f"👻 Skipping Ghost Stock: {stock_code} (Latest trade was SELL)") # DB에서도 명시적 삭제 @@ -1333,6 +1370,7 @@ async def sell_stock( # ✅ delete/commit 후 접근 불가를 대비해 미리 캡처 stock_name = holding.stock_name original_holding_qty = holding.quantity + original_avg_price = float(holding.average_price or 0) # 매도 주문 실행 order_result = await korea_investment_api.place_order( @@ -1395,6 +1433,9 @@ async def sell_stock( if h.stock_code == stock_code: h.quantity -= quantity break + # 예수금/평가액 요약도 즉시 반영 + 동기화가 되살리지 않도록 최근 매도 기록 + portfolio_cache.note_sell(stock_code, quantity) + await portfolio_cache.apply_sell(stock_code, quantity, float(executed_price), original_avg_price) portfolio_cache.mark_changed() logger.info(f"✅ 수동 매도 완료: {stock_name} {quantity}주 @ {executed_price:,.0f}원 (손익: {profit_loss:+,.0f}원)") diff --git a/backend/app/services/daytrading/entry_service.py b/backend/app/services/daytrading/entry_service.py index 0524deb..3ad4ee6 100644 --- a/backend/app/services/daytrading/entry_service.py +++ b/backend/app/services/daytrading/entry_service.py @@ -282,6 +282,10 @@ async def scan_and_enter() -> list[dict]: summary = (balance.get("output2") or [{}])[0] total_assets = float(summary.get("tot_evlu_amt", 0) or 0) available_cash = float(summary.get("prvs_rcdl_excc_amt", 0) or 0) + # 당일 매도대금 재사용 가능 — 주문가능현금이 조회되면 우선 사용 + _buyable = await korea_investment_api.get_buyable_cash() + if _buyable > 0: + available_cash = _buyable day_trading_state.last_total_assets = total_assets except Exception as e: logger.debug(f"[DayEntry] 잔고 조회 실패: {e}") diff --git a/backend/app/services/korea_investment_api.py b/backend/app/services/korea_investment_api.py index 752bdf7..928cf45 100644 --- a/backend/app/services/korea_investment_api.py +++ b/backend/app/services/korea_investment_api.py @@ -1092,6 +1092,44 @@ class KoreaInvestmentAPI: logger.error(f"Error fetching balance: {e}") raise + async def get_buyable_cash(self, stock_code: str = "005930") -> float: + """주문가능현금(ord_psbl_cash) 조회 — 매수가능조회 API. + + 예수금총액(dnca_tot_amt)은 D+2 결제 기준이라 당일 매도대금이 반영되지 않는다. + 국내 주식은 당일 매도대금을 즉시 재매수에 쓸 수 있으므로, 화면 '예수금'과 + 데이트레이딩 가용 현금은 이 값을 우선 사용한다. (2026-09-22) + 실전: TTTC8908R / 모의: VTTC8908R. 실패 시 0.0 반환(호출측에서 폴백). + """ + try: + account_number_prefix = self.account_number[:8] + account_number_suffix = self.account_number[8:] + is_virtual = "vts" in self.base_url.lower() + tr_id = "VTTC8908R" if is_virtual else "TTTC8908R" + result, _ = await self._make_request( + "GET", + "/uapi/domestic-stock/v1/trading/inquire-psbl-order", + tr_id, + params={ + "CANO": account_number_prefix, + "ACNT_PRDT_CD": account_number_suffix, + "PDNO": stock_code, + "ORD_UNPR": "0", + "ORD_DVSN": "01", # 시장가 기준 + "CMA_EVLU_AMT_ICLD_YN": "N", + "OVRS_ICLD_YN": "N", + }, + ) + if result.get("rt_cd") != "0": + logger.warning(f"[get_buyable_cash] rt_cd={result.get('rt_cd')} msg1={result.get('msg1')}") + return 0.0 + out = result.get("output") or {} + if isinstance(out, list): + out = out[0] if out else {} + return float(out.get("ord_psbl_cash", 0) or 0) + except Exception as e: + logger.warning(f"[get_buyable_cash] 조회 실패: {e}") + return 0.0 + async def get_overseas_balance(self) -> Dict[str, Any]: """해외주식 잔고 조회 (미국 기준) diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 78bc2b9..5cc3cd9 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -18,6 +18,7 @@ import { LayoutDashboard, History, Cpu, Activity, SlidersHorizontal, BarChart3, import { useQuery } from '@tanstack/react-query' import api from '../lib/api' import { useTenantMode } from '../lib/tenant' +import ToastHost from './ToastHost' /** Layout Props 정의 */ interface LayoutProps { @@ -81,6 +82,7 @@ const Layout = ({ children }: LayoutProps) => { return (
+ {/* Top Navigation Bar */}
diff --git a/frontend/src/components/PortfolioPanel.tsx b/frontend/src/components/PortfolioPanel.tsx index 5879657..4a6967e 100644 --- a/frontend/src/components/PortfolioPanel.tsx +++ b/frontend/src/components/PortfolioPanel.tsx @@ -91,7 +91,7 @@ const PortfolioItem = React.memo(({ const err = error as any // 계좌 미등록/미검증(400/423)은 Dashboard 상단 인라인 안내로 처리 — alert 생략 if (err?.isAccountSetupRequired) return - alert(`매도 실패: ${err.response?.data?.detail || err.message}`) + // 실패 알림은 useTradeActions의 toast.error로 표시됨 (alert는 화면 블로킹) } } diff --git a/frontend/src/components/RecommendationPanel.tsx b/frontend/src/components/RecommendationPanel.tsx index de538b9..b49c302 100644 --- a/frontend/src/components/RecommendationPanel.tsx +++ b/frontend/src/components/RecommendationPanel.tsx @@ -65,7 +65,7 @@ const RecommendationItem = React.memo(({ const err = error as any // 계좌 미등록/미검증(400/423)은 Dashboard 상단 인라인 안내로 처리 — alert 생략 if (err?.isAccountSetupRequired) return - alert(`매수 실패: ${err.response?.data?.detail || err.message}`) + // 실패 알림은 useTradeActions의 toast.error로 표시됨 (alert는 화면 블로킹) } finally { setBuying(false) } diff --git a/frontend/src/components/ToastHost.tsx b/frontend/src/components/ToastHost.tsx new file mode 100644 index 0000000..146b846 --- /dev/null +++ b/frontend/src/components/ToastHost.tsx @@ -0,0 +1,51 @@ +/** + * ToastHost.tsx — 우상단 토스트 스택. Layout에 1회 마운트. + */ +import React, { useEffect, useState } from 'react' +import { CheckCircle2, XCircle, Info, X } from 'lucide-react' +import { TOAST_EVENT, ToastMessage } from '../lib/toast' + +const STYLE: Record = { + success: { border: 'border-[#22c55e]', icon: }, + error: { border: 'border-[#ef4444]', icon: }, + info: { border: 'border-[#2b65d9]', icon: }, +} + +const ToastHost: React.FC = () => { + const [items, setItems] = useState([]) + + useEffect(() => { + const onToast = (e: Event) => { + const msg = (e as CustomEvent).detail + setItems((prev) => [...prev.slice(-4), msg]) + window.setTimeout(() => setItems((prev) => prev.filter((m) => m.id !== msg.id)), msg.ttl) + } + window.addEventListener(TOAST_EVENT, onToast) + return () => window.removeEventListener(TOAST_EVENT, onToast) + }, []) + + if (items.length === 0) return null + return ( +
+ {items.map((m) => ( +
+ {STYLE[m.kind].icon} + {m.text} + +
+ ))} +
+ ) +} + +export default ToastHost diff --git a/frontend/src/hooks/useTradeActions.ts b/frontend/src/hooks/useTradeActions.ts index 182766d..14f9920 100644 --- a/frontend/src/hooks/useTradeActions.ts +++ b/frontend/src/hooks/useTradeActions.ts @@ -22,6 +22,7 @@ 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 } @@ -58,11 +59,14 @@ export const useTradeActions = () => { // KIS 모의서버 지연 시 주문 1건에 30~60초 걸릴 수 있음 — 기본 15초로 끊으면 // 서버는 체결하는데 화면은 실패로 보여 중복 주문을 유발함 const body = { stock_code: stockCode, quantity, price: null } - if (mode === 'v2') { - await sellStockV2(body) - } else { - await api.post('/portfolio/sell', body, { timeout: 90000 }) - } + 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}`) // Invalidate queries to refresh portfolio queryClient.invalidateQueries({ @@ -79,6 +83,9 @@ export const useTradeActions = () => { `❌ 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 } }, @@ -91,11 +98,11 @@ export const useTradeActions = () => { if (mode === undefined) throw new TenantModeNotReadyError() try { 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 }) - } + 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`) @@ -105,6 +112,9 @@ export const useTradeActions = () => { `❌ 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 } }, diff --git a/frontend/src/lib/toast.ts b/frontend/src/lib/toast.ts new file mode 100644 index 0000000..9e4be2d --- /dev/null +++ b/frontend/src/lib/toast.ts @@ -0,0 +1,23 @@ +/** + * toast.ts — 전역 토스트 알림 (의존성 없음, CustomEvent 기반) + * + * 사용: toast.success('LG 3주 매도 주문 완료') / toast.error('매도 실패: ...') + * 표시: 가 Layout에 마운트되어 window 이벤트를 받아 렌더링 + */ +export type ToastKind = 'success' | 'error' | 'info' +export interface ToastMessage { id: number; kind: ToastKind; text: string; ttl: number } + +export const TOAST_EVENT = 'app-toast' +let _seq = 0 + +const emit = (kind: ToastKind, text: string, ttl = 4000) => { + if (typeof window === 'undefined') return + const detail: ToastMessage = { id: ++_seq, kind, text, ttl } + window.dispatchEvent(new CustomEvent(TOAST_EVENT, { detail })) +} + +export const toast = { + success: (text: string, ttl?: number) => emit('success', text, ttl), + error: (text: string, ttl?: number) => emit('error', text, ttl ?? 6000), + info: (text: string, ttl?: number) => emit('info', text, ttl), +}