Compare commits

..

2 Commits

Author SHA1 Message Date
a54d6addf2 Merge branch 'dev' into main — 보유종목 전략별 분리 표시
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:38:16 +09:00
159d172d59 feat(frontend): 보유종목을 데이트레이딩/스윙/수동·외부/해외로 분리 표시
- portfolioGrouping.ts: DT 엔진 포지션 → 데이트레이딩, strategy_type 으로 swing(+NULL)/external·manual/us_swing 분류
- 스윙+DT 합산 행(Portfolio stock_code UNIQUE)은 DT 엔진 수량만큼 분할 표시, 분할 카드·DT 수량 미상 카드는 청산 버튼 비활성
  (수동 매도는 DT 몫부터 차감되므로 분할 매도 불가 안내)
- 그룹별 종목 수·평가금액·손익 소계, 상단 카운트 데이/스윙/수동/해외 정정
- 매매 로직·백엔드 변경 없음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:36:42 +09:00
2 changed files with 180 additions and 43 deletions

View File

@@ -13,12 +13,13 @@
* 한국/미국 시장 구분:
* - 종목 코드가 6자리 숫자면 KR, 그 외는 US
*/
import React from 'react'
import React, { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import api from '../lib/api'
import { Activity, TrendingUp, Clock } from 'lucide-react'
import { Activity, TrendingUp, Clock, UserCircle, Globe } from 'lucide-react'
import { Position } from '../types/trading'
import { isUsStock } from '../utils/stockUtils'
import { groupHoldings, GroupedHolding } from '../utils/portfolioGrouping'
import AccountSetupNotice from './AccountSetupNotice'
import DayTradingStatusBar from './DayTradingStatusBar'
@@ -95,9 +96,12 @@ const PortfolioItem = React.memo(({
const isSelected = selectedStock === stock.stock_code
const isUp = profitLossRate >= 0
const splitLocked = Number((stock as GroupedHolding).isSplit) > 0 || Number((stock as GroupedHolding).dtIncluded) > 0
const sellBlocked = !!sellDisabled || splitLocked
const handleSellClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
if (sellDisabled) return
if (sellBlocked) return
try {
await onSell(stock.stock_code, stock.stock_name, quantity)
} catch (error: unknown) {
@@ -126,9 +130,25 @@ const PortfolioItem = React.memo(({
<span className={`text-[7px] px-1 py-px rounded-sm flex-shrink-0 font-mono ${isUs ? 'text-[#22c55e] bg-[#22c55e]/10' : 'text-[#5b9aff] bg-[#5b9aff]/10'}`}>
{isUs ? 'US' : 'KR'}
</span>
{stock.strategy_type === 'daytrading' && (
{(stock.strategy_type === 'daytrading' || stock.strategy_type === 'day_trade') && (
<span className="text-[7px] px-1 py-px rounded-sm text-[#f97316] bg-[#f97316]/10 flex-shrink-0">데이</span>
)}
{(stock.strategy_type === 'external' || stock.strategy_type === 'manual') && (
<span
title="자동 청산 대상 아님"
className="text-[7px] px-1 py-px rounded-sm text-[#eab308] bg-[#eab308]/10 flex-shrink-0"
>
{stock.strategy_type === 'external' ? '외부' : '수동'}
</span>
)}
{Number((stock as GroupedHolding).dtIncluded) > 0 && (
<span
title="데이트레이딩 엔진 수량을 확인할 수 없어 DT 보유분이 포함됐을 수 있음"
className="text-[7px] px-1 py-px rounded-sm text-[#f97316]/70 bg-[#f97316]/10 flex-shrink-0"
>
DT 포함?
</span>
)}
</div>
<span className={`text-xs font-mono font-bold flex-shrink-0 ${isUp ? 'text-[#e84444]' : 'text-[#3d7eff]'}`}>
{profitLossRate > 0 ? '+' : ''}{profitLossRate.toFixed(2)}%
@@ -154,7 +174,8 @@ const PortfolioItem = React.memo(({
<div className="px-3 pb-2">
<button
onClick={handleSellClick}
disabled={sellDisabled}
disabled={sellBlocked}
title={splitLocked ? '스윙·데이트레이딩 합산 종목 — DT 포지션은 15:20 자동 청산, 수동 매도 시 DT 몫부터 차감되므로 여기서는 분할 매도할 수 없습니다' : undefined}
className="w-full py-0.5 text-[9px] font-mono text-[#3a5570] hover:text-[#3d7eff] border border-[#152035] hover:border-[#3d7eff]/40 rounded-sm transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-[#3a5570] disabled:hover:border-[#152035]"
>
전량 청산 (시장가)
@@ -192,8 +213,7 @@ interface PortfolioPanelProps {
* PortfolioPanel — 보유 종목 목록 패널
*
* 그룹 구분:
* - 데이트레이딩: strategy_type === 'daytrading' (주황색 배지)
* - 스윙: 그 외 (보라색 배지)
* - 데이트레이딩 / 스윙 / 수동·외부 / 해외 (utils/portfolioGrouping.ts 기준)
*/
const PortfolioPanel = React.memo(({
portfolio,
@@ -209,47 +229,79 @@ const PortfolioPanel = React.memo(({
// 데이트레이딩 포지션은 매매 엔진(인메모리)이 진실 — Portfolio 테이블은 종목당 1행이라
// 같은 종목을 스윙+데이트레이딩으로 동시 보유하면 구분이 사라진다. 엔진에서 직접 조회해
// 데이트레이딩 그룹으로 표시하고, 스윙 행에서는 그 수량을 뺀다. (2026-09-23)
const { data: dtPositions } = useQuery<DayTradePosition[]>({
const { data: dtPositions, isError: dtError } = 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 dtRows: Position[] | undefined = useMemo(() => {
if (dtError) return undefined // 엔진 수량 불명 -> 합산 행에 "DT 포함" 배지
return (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',
}))
}, [dtPositions, dtError])
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 groups = useMemo(() => groupHoldings(portfolio, dtRows), [portfolio, dtRows])
const total = groups.daytrading.length + groups.swing.length + groups.manual.length + groups.overseas.length
const renderGroup = (stocks: Position[], label: string, icon: React.ReactNode, color: string) => {
/** 그룹 소계 (실시간 가격 우선). 통화가 다르므로 그룹 내 같은 통화끼리만 합산 */
const subtotal = (stocks: Position[]) => {
let value = 0
let cost = 0
for (const st of stocks) {
const qty = Number(st.quantity || 0)
const avg = Number(st.average_price || 0)
const cur = livePrices?.[st.stock_code] ?? Number(st.current_price || 0)
value += cur * qty
cost += avg * qty
}
return { value, pnl: value - cost, rate: cost > 0 ? ((value - cost) / cost) * 100 : 0 }
}
const renderGroup = (
stocks: GroupedHolding[],
label: string,
icon: React.ReactNode,
color: string,
note?: string,
usd?: boolean,
) => {
if (stocks.length === 0) return null
const sub = subtotal(stocks)
const up = sub.pnl >= 0
const fmt = (n: number) => usd
? `$${Math.round(n).toLocaleString('en-US')}`
: `${Math.round(n).toLocaleString()}원`
return (
<>
<div className="flex items-center gap-1.5 px-3 py-1 bg-[#091018] border-b border-[#152035] sticky top-0 z-10">
{icon}
<span className={`text-[9px] font-semibold uppercase tracking-wider ${color}`}>{label}</span>
<span className="ml-auto text-[8px] font-mono bg-[#152035] text-[#4a6580] px-1.5 py-px rounded-sm">{stocks.length}</span>
<section key={label}>
<div className="px-3 py-1 bg-[#091018] border-b border-[#152035] sticky top-0 z-10" title={note}>
<div className="flex items-center gap-1.5">
{icon}
<span className={`text-[9px] font-semibold uppercase tracking-wider ${color}`}>{label}</span>
<span className="ml-auto text-[8px] font-mono bg-[#152035] text-[#4a6580] px-1.5 py-px rounded-sm">{stocks.length}</span>
</div>
<div className="flex justify-between items-center mt-0.5 text-[8px] font-mono text-[#4a6580]">
<span>{fmt(sub.value)}</span>
<span className={up ? 'text-[#e84444]' : 'text-[#3d7eff]'}>
{up ? '+' : ''}{fmt(sub.pnl)} ({up ? '+' : ''}{sub.rate.toFixed(2)}%)
</span>
</div>
{note && <p className="mt-0.5 text-[8px] text-[#3a5570] leading-tight">{note}</p>}
</div>
{stocks.map((stock: Position) => (
{stocks.map((stock) => (
<PortfolioItem
key={stock.stock_code}
key={`${label}-${stock.stock_code}`}
stock={stock}
livePrices={livePrices}
selectedStock={selectedStock}
@@ -259,7 +311,7 @@ const PortfolioPanel = React.memo(({
sellDisabled={sellDisabled}
/>
))}
</>
</section>
)
}
@@ -272,11 +324,17 @@ const PortfolioPanel = React.memo(({
보유 종목
</h3>
<div className="flex items-center gap-1">
{dayStocks.length > 0 && (
<span className="text-[8px] font-mono text-[#f97316] bg-[#f97316]/10 px-1.5 py-px rounded-sm">데이 {dayStocks.length}</span>
{groups.daytrading.length > 0 && (
<span className="text-[8px] font-mono text-[#f97316] bg-[#f97316]/10 px-1.5 py-px rounded-sm">데이 {groups.daytrading.length}</span>
)}
{swingStocks.length > 0 && (
<span className="text-[8px] font-mono text-[#a78bfa] bg-[#a78bfa]/10 px-1.5 py-px rounded-sm">스윙 {swingStocks.length}</span>
{groups.swing.length > 0 && (
<span className="text-[8px] font-mono text-[#a78bfa] bg-[#a78bfa]/10 px-1.5 py-px rounded-sm">스윙 {groups.swing.length}</span>
)}
{groups.manual.length > 0 && (
<span className="text-[8px] font-mono text-[#eab308] bg-[#eab308]/10 px-1.5 py-px rounded-sm">수동 {groups.manual.length}</span>
)}
{groups.overseas.length > 0 && (
<span className="text-[8px] font-mono text-[#22c55e] bg-[#22c55e]/10 px-1.5 py-px rounded-sm">해외 {groups.overseas.length}</span>
)}
<span className="text-[8px] font-mono text-[#2b65d9] bg-[#2b65d9]/10 px-1.5 py-px rounded-sm">{total}</span>
</div>
@@ -290,8 +348,10 @@ const PortfolioPanel = React.memo(({
</div>
) : total > 0 ? (
<>
{renderGroup(dayStocks, '데이트레이딩', <Clock className="w-2.5 h-2.5 text-[#f97316]" />, 'text-[#f97316]')}
{renderGroup(swingStocks, '스윙', <TrendingUp className="w-2.5 h-2.5 text-[#a78bfa]" />, 'text-[#a78bfa]')}
{renderGroup(groups.daytrading, '데이트레이딩', <Clock className="w-2.5 h-2.5 text-[#f97316]" />, 'text-[#f97316]', '15:20 자동 청산 (당일 청산 원칙)')}
{renderGroup(groups.swing, '스윙', <TrendingUp className="w-2.5 h-2.5 text-[#a78bfa]" />, 'text-[#a78bfa]')}
{renderGroup(groups.manual, '수동 / 외부', <UserCircle className="w-2.5 h-2.5 text-[#eab308]" />, 'text-[#eab308]', '자동 청산 대상 아님 - 직접 청산 필요')}
{renderGroup(groups.overseas, '해외', <Globe className="w-2.5 h-2.5 text-[#22c55e]" />, 'text-[#22c55e]', undefined, true)}
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-[#3a5570] p-4 text-center">

View File

@@ -0,0 +1,77 @@
/**
* portfolioGrouping.ts — 보유종목 그룹 분류 (순수 함수, 표시 전용)
*
* 우선순위:
* 1. DT 엔진 인메모리 포지션(/daytrading/positions) 수량 -> 데이트레이딩
* 2. 행의 strategy_type: daytrading/day_trade -> 데이트레이딩(엔진 수량을 모를 때 행 전체)
* external/manual -> 수동/외부, us_swing -> 해외, swing/NULL -> 스윙
* Portfolio 는 stock_code UNIQUE 라 스윙+DT 수량이 합산돼 있으므로 DT 엔진 수량을 빼서 나눈다.
*/
import { Position } from '../types/trading'
export type PortfolioGroupKey = 'daytrading' | 'swing' | 'manual' | 'overseas'
export interface GroupedHolding extends Position {
/** 합산 행에서 DT 수량을 알 수 없어 DT 가 포함됐을 수 있음 */
dtIncluded?: number
/** 스윙+DT 합산 행에서 분할된 카드(1) — 수동매도는 DT 몫부터 차감되므로 청산 비활성 */
isSplit?: number
}
export type PortfolioGroups = Record<PortfolioGroupKey, GroupedHolding[]>
const DT_TYPES = new Set(['daytrading', 'day_trade'])
const MANUAL_TYPES = new Set(['external', 'manual'])
export function classifyStrategyType(type?: string | null): PortfolioGroupKey {
const t = (type ?? '').toLowerCase()
if (DT_TYPES.has(t)) return 'daytrading'
if (MANUAL_TYPES.has(t)) return 'manual'
if (t === 'us_swing') return 'overseas'
return 'swing'
}
/**
* @param holdings 보유 행 (Portfolio)
* @param dtPositions DT 엔진 포지션. undefined=조회 전/실패(수량 불명), 배열=확정
*/
export function groupHoldings(
holdings: Position[] | undefined,
dtPositions: Position[] | undefined,
): PortfolioGroups {
const groups: PortfolioGroups = { daytrading: [], swing: [], manual: [], overseas: [] }
const dtKnown = dtPositions !== undefined
const dtQty = new Map<string, number>()
for (const d of dtPositions ?? []) {
if (Number(d.quantity) > 0) dtQty.set(d.stock_code, Number(d.quantity))
}
const rowCodes = new Set((holdings ?? []).map(h => h.stock_code))
// DT 엔진에는 있으나 Portfolio 행이 아직 없는 포지션도 DT 그룹에 표시
for (const d of dtPositions ?? []) {
if (Number(d.quantity) > 0 && !rowCodes.has(d.stock_code)) groups.daytrading.push(d)
}
for (const row of holdings ?? []) {
const rowQty = Number(row.quantity || 0)
const kind = classifyStrategyType(row.strategy_type)
const dq = dtQty.get(row.stock_code) ?? 0
if (dq > 0) {
const dtShown = Math.min(dq, rowQty)
const rest = rowQty - dtShown
groups.daytrading.push({ ...row, quantity: dtShown, strategy_type: 'daytrading', isSplit: rest > 0 ? 1 : undefined })
if (rest > 0) groups[kind === 'daytrading' ? 'swing' : kind].push({ ...row, quantity: rest, isSplit: 1 })
continue
}
if (kind === 'daytrading') {
groups.daytrading.push(row)
} else if (kind === 'swing' && !dtKnown) {
// 엔진 수량을 알 수 없는 스윙 행 — DT 가 합산됐을 수 있음
groups.swing.push({ ...row, dtIncluded: 1 })
} else {
groups[kind].push(row)
}
}
return groups
}