Merge branch 'dev' into main — 보유종목 전략별 분리 표시
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,12 +13,13 @@
|
|||||||
* 한국/미국 시장 구분:
|
* 한국/미국 시장 구분:
|
||||||
* - 종목 코드가 6자리 숫자면 KR, 그 외는 US
|
* - 종목 코드가 6자리 숫자면 KR, 그 외는 US
|
||||||
*/
|
*/
|
||||||
import React from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import api from '../lib/api'
|
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 { Position } from '../types/trading'
|
||||||
import { isUsStock } from '../utils/stockUtils'
|
import { isUsStock } from '../utils/stockUtils'
|
||||||
|
import { groupHoldings, GroupedHolding } from '../utils/portfolioGrouping'
|
||||||
import AccountSetupNotice from './AccountSetupNotice'
|
import AccountSetupNotice from './AccountSetupNotice'
|
||||||
import DayTradingStatusBar from './DayTradingStatusBar'
|
import DayTradingStatusBar from './DayTradingStatusBar'
|
||||||
|
|
||||||
@@ -95,9 +96,12 @@ const PortfolioItem = React.memo(({
|
|||||||
const isSelected = selectedStock === stock.stock_code
|
const isSelected = selectedStock === stock.stock_code
|
||||||
const isUp = profitLossRate >= 0
|
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>) => {
|
const handleSellClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (sellDisabled) return
|
if (sellBlocked) return
|
||||||
try {
|
try {
|
||||||
await onSell(stock.stock_code, stock.stock_name, quantity)
|
await onSell(stock.stock_code, stock.stock_name, quantity)
|
||||||
} catch (error: unknown) {
|
} 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'}`}>
|
<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'}
|
{isUs ? 'US' : 'KR'}
|
||||||
</span>
|
</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>
|
<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>
|
</div>
|
||||||
<span className={`text-xs font-mono font-bold flex-shrink-0 ${isUp ? 'text-[#e84444]' : 'text-[#3d7eff]'}`}>
|
<span className={`text-xs font-mono font-bold flex-shrink-0 ${isUp ? 'text-[#e84444]' : 'text-[#3d7eff]'}`}>
|
||||||
{profitLossRate > 0 ? '+' : ''}{profitLossRate.toFixed(2)}%
|
{profitLossRate > 0 ? '+' : ''}{profitLossRate.toFixed(2)}%
|
||||||
@@ -154,7 +174,8 @@ const PortfolioItem = React.memo(({
|
|||||||
<div className="px-3 pb-2">
|
<div className="px-3 pb-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleSellClick}
|
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]"
|
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 — 보유 종목 목록 패널
|
* PortfolioPanel — 보유 종목 목록 패널
|
||||||
*
|
*
|
||||||
* 그룹 구분:
|
* 그룹 구분:
|
||||||
* - 데이트레이딩: strategy_type === 'daytrading' (주황색 배지)
|
* - 데이트레이딩 / 스윙 / 수동·외부 / 해외 (utils/portfolioGrouping.ts 기준)
|
||||||
* - 스윙: 그 외 (보라색 배지)
|
|
||||||
*/
|
*/
|
||||||
const PortfolioPanel = React.memo(({
|
const PortfolioPanel = React.memo(({
|
||||||
portfolio,
|
portfolio,
|
||||||
@@ -209,14 +229,16 @@ const PortfolioPanel = React.memo(({
|
|||||||
// 데이트레이딩 포지션은 매매 엔진(인메모리)이 진실 — Portfolio 테이블은 종목당 1행이라
|
// 데이트레이딩 포지션은 매매 엔진(인메모리)이 진실 — Portfolio 테이블은 종목당 1행이라
|
||||||
// 같은 종목을 스윙+데이트레이딩으로 동시 보유하면 구분이 사라진다. 엔진에서 직접 조회해
|
// 같은 종목을 스윙+데이트레이딩으로 동시 보유하면 구분이 사라진다. 엔진에서 직접 조회해
|
||||||
// 데이트레이딩 그룹으로 표시하고, 스윙 행에서는 그 수량을 뺀다. (2026-09-23)
|
// 데이트레이딩 그룹으로 표시하고, 스윙 행에서는 그 수량을 뺀다. (2026-09-23)
|
||||||
const { data: dtPositions } = useQuery<DayTradePosition[]>({
|
const { data: dtPositions, isError: dtError } = useQuery<DayTradePosition[]>({
|
||||||
queryKey: ['daytrading-positions'],
|
queryKey: ['daytrading-positions'],
|
||||||
queryFn: async () => (await api.get('/daytrading/positions')).data?.positions ?? [],
|
queryFn: async () => (await api.get('/daytrading/positions')).data?.positions ?? [],
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
staleTime: 15000,
|
staleTime: 15000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const dayStocks: Position[] = (dtPositions ?? [])
|
const dtRows: Position[] | undefined = useMemo(() => {
|
||||||
|
if (dtError) return undefined // 엔진 수량 불명 -> 합산 행에 "DT 포함" 배지
|
||||||
|
return (dtPositions ?? [])
|
||||||
.filter(p => p.quantity > 0)
|
.filter(p => p.quantity > 0)
|
||||||
.map(p => ({
|
.map(p => ({
|
||||||
stock_code: p.stock_code,
|
stock_code: p.stock_code,
|
||||||
@@ -228,28 +250,58 @@ const PortfolioPanel = React.memo(({
|
|||||||
strategy_type: 'daytrading',
|
strategy_type: 'daytrading',
|
||||||
market: 'KRX',
|
market: 'KRX',
|
||||||
}))
|
}))
|
||||||
|
}, [dtPositions, dtError])
|
||||||
|
|
||||||
const dtQty = new Map(dayStocks.map(d => [d.stock_code, d.quantity]))
|
const groups = useMemo(() => groupHoldings(portfolio, dtRows), [portfolio, dtRows])
|
||||||
const swingStocks = (portfolio ?? [])
|
const total = groups.daytrading.length + groups.swing.length + groups.manual.length + groups.overseas.length
|
||||||
.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) => {
|
/** 그룹 소계 (실시간 가격 우선). 통화가 다르므로 그룹 내 같은 통화끼리만 합산 */
|
||||||
|
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
|
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 (
|
return (
|
||||||
<>
|
<section key={label}>
|
||||||
<div className="flex items-center gap-1.5 px-3 py-1 bg-[#091018] border-b border-[#152035] sticky top-0 z-10">
|
<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}
|
{icon}
|
||||||
<span className={`text-[9px] font-semibold uppercase tracking-wider ${color}`}>{label}</span>
|
<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>
|
<span className="ml-auto text-[8px] font-mono bg-[#152035] text-[#4a6580] px-1.5 py-px rounded-sm">{stocks.length}</span>
|
||||||
</div>
|
</div>
|
||||||
{stocks.map((stock: Position) => (
|
<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) => (
|
||||||
<PortfolioItem
|
<PortfolioItem
|
||||||
key={stock.stock_code}
|
key={`${label}-${stock.stock_code}`}
|
||||||
stock={stock}
|
stock={stock}
|
||||||
livePrices={livePrices}
|
livePrices={livePrices}
|
||||||
selectedStock={selectedStock}
|
selectedStock={selectedStock}
|
||||||
@@ -259,7 +311,7 @@ const PortfolioPanel = React.memo(({
|
|||||||
sellDisabled={sellDisabled}
|
sellDisabled={sellDisabled}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,11 +324,17 @@ const PortfolioPanel = React.memo(({
|
|||||||
보유 종목
|
보유 종목
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{dayStocks.length > 0 && (
|
{groups.daytrading.length > 0 && (
|
||||||
<span className="text-[8px] font-mono text-[#f97316] bg-[#f97316]/10 px-1.5 py-px rounded-sm">데이 {dayStocks.length}</span>
|
<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 && (
|
{groups.swing.length > 0 && (
|
||||||
<span className="text-[8px] font-mono text-[#a78bfa] bg-[#a78bfa]/10 px-1.5 py-px rounded-sm">스윙 {swingStocks.length}</span>
|
<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>
|
<span className="text-[8px] font-mono text-[#2b65d9] bg-[#2b65d9]/10 px-1.5 py-px rounded-sm">{total}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -290,8 +348,10 @@ const PortfolioPanel = React.memo(({
|
|||||||
</div>
|
</div>
|
||||||
) : total > 0 ? (
|
) : total > 0 ? (
|
||||||
<>
|
<>
|
||||||
{renderGroup(dayStocks, '데이트레이딩', <Clock className="w-2.5 h-2.5 text-[#f97316]" />, 'text-[#f97316]')}
|
{renderGroup(groups.daytrading, '데이트레이딩', <Clock className="w-2.5 h-2.5 text-[#f97316]" />, 'text-[#f97316]', '15:20 자동 청산 (당일 청산 원칙)')}
|
||||||
{renderGroup(swingStocks, '스윙', <TrendingUp className="w-2.5 h-2.5 text-[#a78bfa]" />, 'text-[#a78bfa]')}
|
{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">
|
<div className="h-full flex flex-col items-center justify-center text-[#3a5570] p-4 text-center">
|
||||||
|
|||||||
77
frontend/src/utils/portfolioGrouping.ts
Normal file
77
frontend/src/utils/portfolioGrouping.ts
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user