/** * DtPositionCards.tsx — 데이트레이딩 포지션 카드 탭 * 손절-현재-목표 게이지, 경과/시간손절, 트레일링, 일일 손실한도. * 소스: GET /daytrading/positions (기존) + GET /positions/overlay (신규, 실패 시 기존 필드만으로 축소 표시) */ import React from 'react' import { useQuery } from '@tanstack/react-query' import api from '../../lib/api' import { usePositionOverlay, StrategyDisplay, OverlayPosition } from '../../lib/tvApi' import { fmtPrice } from './chartUtils' interface DtPos { stock_code: string; stock_name?: string; strategy?: string; entry_price?: number; current_price?: number | null stop_loss?: number | null; target_price?: number | null; partial_sold?: boolean; quantity?: number pnl?: number | null; pnl_pct?: number | null; entry_time?: string } interface DtResp { positions: DtPos[]; daily_pnl?: number; is_daily_loss_exceeded?: boolean } const pnlCls = (v?: number | null) => (v == null ? 'text-tv-muted' : v > 0 ? 'text-tv-up' : v < 0 ? 'text-tv-down' : 'text-tv-text') const signed = (v?: number | null, d = 0) => (v == null ? '-' : `${v > 0 ? '+' : ''}${v.toLocaleString('ko-KR', { maximumFractionDigits: d })}`) const Gauge = ({ stop, cur, target, entry }: { stop?: number | null; cur?: number | null; target?: number | null; entry?: number | null }) => { if (stop == null || target == null || cur == null || !(target > stop)) { return
손절/목표 데이터 없음{stop != null ? ` · 손절 ${fmtPrice(stop)}` : ''}
} const pos = (v: number) => Math.max(0, Math.min(100, ((v - stop) / (target - stop)) * 100)) return (
{entry != null &&
}
손절 {fmtPrice(stop)} 현재 {fmtPrice(cur)} 목표 {fmtPrice(target)}
) } export const DtPositionCards = React.memo(({ cfg, totalAssets }: { cfg: StrategyDisplay | null; totalAssets?: number }) => { const { data, isLoading, isError } = useQuery({ queryKey: ['tv-dt-positions'], queryFn: async () => (await api.get('/daytrading/positions')).data, refetchInterval: 15000, staleTime: 8000, retry: false, }) const { data: ov } = usePositionOverlay(undefined) const ovMap = new Map() for (const p of ov?.positions ?? []) { const st = (p.strategy ?? '').toLowerCase() if (st === 'dt' || st === 'daytrading' || st === 'day_trade') ovMap.set(p.stock_code, p) } if (isLoading) return
불러오는 중…
if (isError || !data) return
DT 포지션 데이터 없음
const lim = cfg?.dt?.daily_loss_pct const limPct = typeof lim === 'number' ? Math.abs(lim) : null // 백엔드 계약: 퍼센트 단위(-3.0) const limAmt = limPct != null && totalAssets ? (totalAssets * limPct) / 100 : null const daily = data.daily_pnl ?? 0 const used = limAmt && daily < 0 ? Math.min(100, (Math.abs(daily) / limAmt) * 100) : 0 const tsMin = cfg?.dt?.time_stop_min return (
일일 손익(순) {signed(daily)}원 한도 {limPct != null ? `-${limPct}%` : '-'}{limAmt != null ? ` (≈-${Math.round(limAmt).toLocaleString('ko-KR')}원)` : ''} {limAmt != null && ( = 80 ? 'bg-tv-up' : 'bg-tv-warn'}`} style={{ width: `${used}%` }} /> )} {data.is_daily_loss_exceeded && 한도 초과 — 당일 중단}
{data.positions.length === 0 &&
보유 중인 DT 포지션 없음
}
{data.positions.map((p) => { const o = ovMap.get(p.stock_code) const stop = o?.stop_loss ?? p.stop_loss const target = o?.target_price ?? p.target_price const cur = o?.current_price ?? p.current_price const el = o?.elapsed_min const tsPct = el != null && tsMin ? Math.min(100, (el / tsMin) * 100) : null return (
{p.stock_name || p.stock_code} {p.stock_code} {p.strategy || 'DT'} {p.partial_sold && 분할익절 후} {signed(o?.pnl_pct ?? p.pnl_pct, 2)}%
수량 {p.quantity ?? '-'} 평가손익 {signed(o?.pnl ?? p.pnl)} 경과 {el != null ? `${Math.round(el)}분` : '-'} {tsMin ? ` / 시간손절 ${tsMin}분` : ''} 트레일링 {o?.trailing_active ? {fmtPrice(o.trailing_stop)} : {o ? '미발동' : '-'}}
{tsPct != null && (
= 100 ? 'bg-tv-warn' : 'bg-tv-muted'}`} style={{ width: `${tsPct}%` }} />
)}
) })}
) }) DtPositionCards.displayName = 'DtPositionCards'