/**
* 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 (
손절 {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'