diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py index 9166f48..55eb072 100644 --- a/backend/app/api/endpoints/portfolio.py +++ b/backend/app/api/endpoints/portfolio.py @@ -606,9 +606,13 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) # 데이트레이딩 엔진이 보유 중인 종목 → strategy_type='daytrading' 태깅용 try: from app.services.daytrading.position_manager import day_trading_state as _dts - _dt_codes = set(_dts.positions.keys()) + _dt_qty = { + code: int(pos.get("quantity", 0) or 0) + for code, pos in _dts.positions.items() + } + _dt_codes = set(_dt_qty) except Exception: - _dt_codes = set() + _dt_qty, _dt_codes = {}, set() for item in balance_data["output1"]: stock_code = item.get("pdno") @@ -746,7 +750,9 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) h.profit_loss = profit_loss h.profit_loss_rate = profit_loss_rate h.market = exchange_code - if stock_code in _dt_codes: + # 같은 종목을 스윙+DT로 동시 보유하면 Portfolio 1행에 합쳐지므로, + # 전량이 DT 포지션일 때만 태깅한다(부분 혼재 시 스윙 청산 대상 유지). + if stock_code in _dt_codes and quantity <= _dt_qty.get(stock_code, 0): h.strategy_type = "daytrading" new_holdings.append(h) else: @@ -759,7 +765,11 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) profit_loss=profit_loss, profit_loss_rate=profit_loss_rate, market=exchange_code, - strategy_type="daytrading" if stock_code in _dt_codes else "swing", + strategy_type=( + "daytrading" + if quantity <= _dt_qty.get(stock_code, 0) + else "swing" + ), ) db.add(new_holding) new_holdings.append(new_holding) diff --git a/backend/tests/test_portfolio_cache_bridge.py b/backend/tests/test_portfolio_cache_bridge.py index 499c688..1e764f7 100644 --- a/backend/tests/test_portfolio_cache_bridge.py +++ b/backend/tests/test_portfolio_cache_bridge.py @@ -46,3 +46,21 @@ async def test_reflect_sell_unknown_holding_does_not_zero_db(): with patch("app.services.db_worker.enqueue_portfolio_upsert", q): await bridge.reflect_sell("000660", 5, 100000.0) q.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_dt_and_swing_same_stock_quantities_are_separable(): + """같은 종목을 스윙+DT로 보유해도 화면상 수량이 분리 가능해야 한다. + + Portfolio는 종목당 1행(stock_code unique)이므로 합계만 남는다 → + 프론트는 엔진 포지션 수량을 빼서 스윙 수량을 계산한다. 여기서는 그 계산식을 검증. + """ + q = AsyncMock() + with patch("app.services.db_worker.enqueue_portfolio_upsert", q): + await bridge.reflect_buy("064400", "LG씨엔에스", 8, 70148.0, strategy_type="swing") + await bridge.reflect_buy("064400", "LG씨엔에스", 4, 70800.0, strategy_type="daytrading") + + row = portfolio_cache.holdings[0] + assert row.quantity == 12 + dt_qty = 4 + assert row.quantity - dt_qty == 8 # 스윙 잔량 diff --git a/frontend/src/components/PortfolioPanel.tsx b/frontend/src/components/PortfolioPanel.tsx index 4a6967e..7bbb956 100644 --- a/frontend/src/components/PortfolioPanel.tsx +++ b/frontend/src/components/PortfolioPanel.tsx @@ -14,6 +14,8 @@ * - 종목 코드가 6자리 숫자면 KR, 그 외는 US */ import React from 'react' +import { useQuery } from '@tanstack/react-query' +import api from '../lib/api' import { Activity, TrendingUp, Clock } from 'lucide-react' import { Position } from '../types/trading' import { isUsStock } from '../utils/stockUtils' @@ -32,6 +34,17 @@ const formatPrice = (price: number, isUs: boolean): string => { return `${price.toLocaleString()}원` } +/** 데이트레이딩 엔진 포지션 (GET /daytrading/positions) */ +interface DayTradePosition { + stock_code: string + stock_name: string + quantity: number + entry_price: number + current_price?: number + pnl_pct?: number + strategy?: string +} + /** PortfolioItem Props 정의 */ interface PortfolioItemProps { /** 보유 종목 데이터 */ @@ -193,9 +206,37 @@ const PortfolioPanel = React.memo(({ accountSetupRequired, accountSetupMessage, }: PortfolioPanelProps) => { - const dayStocks = portfolio?.filter(s => s.strategy_type === 'daytrading') ?? [] - const swingStocks = portfolio?.filter(s => s.strategy_type !== 'daytrading') ?? [] - const total = portfolio?.length ?? 0 + // 데이트레이딩 포지션은 매매 엔진(인메모리)이 진실 — Portfolio 테이블은 종목당 1행이라 + // 같은 종목을 스윙+데이트레이딩으로 동시 보유하면 구분이 사라진다. 엔진에서 직접 조회해 + // 데이트레이딩 그룹으로 표시하고, 스윙 행에서는 그 수량을 뺀다. (2026-09-23) + const { data: dtPositions } = useQuery({ + queryKey: ['daytrading-positions'], + queryFn: async () => (await api.get('/daytrading/positions')).data?.positions ?? [], + refetchInterval: 30000, + staleTime: 15000, + }) + + const dayStocks: Position[] = (dtPositions ?? []) + .filter(p => p.quantity > 0) + .map(p => ({ + stock_code: p.stock_code, + stock_name: p.stock_name, + quantity: p.quantity, + average_price: p.entry_price, + current_price: p.current_price ?? p.entry_price, + profit_loss_rate: p.pnl_pct ?? 0, + strategy_type: 'daytrading', + market: 'KRX', + })) + + const dtQty = new Map(dayStocks.map(d => [d.stock_code, d.quantity])) + const swingStocks = (portfolio ?? []) + .map(s => { + const dq = dtQty.get(s.stock_code) ?? 0 + return dq > 0 ? { ...s, quantity: s.quantity - dq } : s + }) + .filter(s => s.quantity > 0) + const total = dayStocks.length + swingStocks.length const renderGroup = (stocks: Position[], label: string, icon: React.ReactNode, color: string) => { if (stocks.length === 0) return null