fix: 데이트레이딩 보유 종목이 화면에 안 보이던 문제 — 엔진 포지션 직접 표시

Portfolio 테이블은 stock_code가 unique라 같은 종목을 스윙+데이트레이딩으로 동시 보유하면
한 행에 합쳐지고 strategy_type도 하나만 남는다(LG씨엔에스 스윙 8 + DT 4 = 12주 'swing').
- 프론트: /daytrading/positions(엔진 인메모리)를 직접 조회해 '데이트레이딩' 그룹으로 표시하고,
  스윙 행에서는 그 수량을 차감
- 백엔드 동기화: 전량이 DT 포지션일 때만 'daytrading' 태깅 (부분 혼재 시 스윙 청산 대상 유지)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 09:33:14 +09:00
parent 9fd02932fa
commit fd8a0f1188
3 changed files with 76 additions and 7 deletions

View File

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

View File

@@ -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 # 스윙 잔량

View File

@@ -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<DayTradePosition[]>({
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