Compare commits
2 Commits
dcab7ebb39
...
a54d6addf2
| Author | SHA1 | Date | |
|---|---|---|---|
| a54d6addf2 | |||
| 159d172d59 |
@@ -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,14 +229,16 @@ 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 ?? [])
|
||||
const dtRows: Position[] | undefined = useMemo(() => {
|
||||
if (dtError) return undefined // 엔진 수량 불명 -> 합산 행에 "DT 포함" 배지
|
||||
return (dtPositions ?? [])
|
||||
.filter(p => p.quantity > 0)
|
||||
.map(p => ({
|
||||
stock_code: p.stock_code,
|
||||
@@ -228,28 +250,58 @@ const PortfolioPanel = React.memo(({
|
||||
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">
|
||||
<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>
|
||||
{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
|
||||
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">
|
||||
|
||||
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