feat: TradingView 스타일 대시보드 + 차트 오버레이 API, Crisis 손절 소급 차단 플래그(기본 OFF)

- frontend: 대시보드를 TradingView 레이아웃으로 재구성(components/tv/*) — 캔들+MA/BB/거래량,
  평단·손절·목표·트레일링 가격선, 체결 마커, RSI 기준선은 실효 설정값, 하단 탭(보유·체결·DT 카드·
  비용 후 손익곡선/낙폭·저널), 디자인 토큰·한국식 색·최소 11px, 모바일 가로 스크롤 제거
- backend(GET 전용, require_api_key): /positions/overlay, /risk/equity-history,
  /strategy-config/display, /trades 응답 필드(side·strategy_type·commission·net_profit_loss),
  /risk/mdd-status thresholds, /market-data/chart 1m|5m(캐시·in-flight 공유)
- mean_reversion_strategy: RSI 청산 임계를 resolve_swing_exit_rsi_v2 로 추출(동작 동일) —
  청산과 화면 표시가 같은 값을 쓴다
- §8.5-39: SWING_STOP_IGNORE_CRISIS_OVERLAY(기본 False) — 반사실 재생에서 Crisis 조기 손절이
  오히려 손실을 줄여 기본 OFF 유지, 정식 백테스트 전 켜지 말 것
- 유닛 스위트 2816 passed / 0 failed, tsc·build 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 18:47:00 +09:00
parent d140055e64
commit a45007cbb2
28 changed files with 2607 additions and 351 deletions

View File

@@ -0,0 +1,126 @@
/**
* 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 <div className="text-[11px] text-tv-muted">손절/목표 데이터 없음{stop != null ? ` · 손절 ${fmtPrice(stop)}` : ''}</div>
}
const pos = (v: number) => Math.max(0, Math.min(100, ((v - stop) / (target - stop)) * 100))
return (
<div>
<div className="relative h-2.5 rounded bg-tv-raised overflow-hidden">
<div className="absolute inset-y-0 left-0 bg-tv-down/40" style={{ width: `${entry != null ? pos(entry) : 0}%` }} />
<div className="absolute inset-y-0 right-0 bg-tv-up/40" style={{ width: `${entry != null ? 100 - pos(entry) : 0}%` }} />
{entry != null && <div className="absolute inset-y-0 w-px bg-tv-text/60" style={{ left: `${pos(entry)}%` }} />}
<div className="absolute -top-0.5 bottom-[-2px] w-1 bg-tv-text rounded" style={{ left: `calc(${pos(cur)}% - 2px)` }} />
</div>
<div className="flex justify-between text-[11px] font-mono mt-0.5">
<span className="text-tv-down">손절 {fmtPrice(stop)}</span>
<span className="text-tv-text">현재 {fmtPrice(cur)}</span>
<span className="text-tv-up">목표 {fmtPrice(target)}</span>
</div>
</div>
)
}
export const DtPositionCards = React.memo(({ cfg, totalAssets }: { cfg: StrategyDisplay | null; totalAssets?: number }) => {
const { data, isLoading, isError } = useQuery<DtResp>({
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<string, OverlayPosition>()
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 <div className="p-4 text-sm text-tv-muted">불러오는 중…</div>
if (isError || !data) return <div className="p-4 text-sm text-tv-muted">DT 포지션 데이터 없음</div>
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 (
<div className="p-2 flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-3 px-2 py-1.5 rounded border border-tv-border bg-tv-panel text-xs">
<span className="text-tv-muted">일일 손익(순)</span>
<b className={`font-mono ${pnlCls(daily)}`}>{signed(daily)}원</b>
<span className="text-tv-muted">한도</span>
<span className="font-mono">{limPct != null ? `-${limPct}%` : '-'}{limAmt != null ? ` (≈-${Math.round(limAmt).toLocaleString('ko-KR')}원)` : ''}</span>
{limAmt != null && (
<span className="flex-1 min-w-[80px] h-1.5 rounded bg-tv-raised overflow-hidden">
<span className={`block h-full ${used >= 80 ? 'bg-tv-up' : 'bg-tv-warn'}`} style={{ width: `${used}%` }} />
</span>
)}
{data.is_daily_loss_exceeded && <span className="px-1.5 rounded bg-tv-up/20 text-tv-up font-semibold">한도 초과 — 당일 중단</span>}
</div>
{data.positions.length === 0 && <div className="p-4 text-sm text-tv-muted text-center">보유 중인 DT 포지션 없음</div>}
<div className="grid gap-2 grid-cols-1 md:grid-cols-2 xl:grid-cols-3">
{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 (
<div key={p.stock_code} className="rounded border border-tv-border bg-tv-panel p-2 text-xs">
<div className="flex items-baseline gap-2 mb-1.5">
<b className="text-sm truncate">{p.stock_name || p.stock_code}</b>
<span className="text-tv-muted font-mono">{p.stock_code}</span>
<span className="px-1 rounded border border-tv-border text-tv-muted">{p.strategy || 'DT'}</span>
{p.partial_sold && <span className="px-1 rounded bg-tv-warn/15 text-tv-warn">분할익절 후</span>}
<span className={`ml-auto font-mono font-semibold ${pnlCls(o?.pnl_pct ?? p.pnl_pct)}`}>
{signed(o?.pnl_pct ?? p.pnl_pct, 2)}%
</span>
</div>
<Gauge stop={stop} cur={cur} target={target} entry={o?.avg_price ?? o?.entry_price ?? p.entry_price} />
<div className="mt-1.5 grid grid-cols-2 gap-x-3 gap-y-0.5 text-[11px]">
<span className="text-tv-muted">수량 <b className="text-tv-text font-mono">{p.quantity ?? '-'}</b></span>
<span className="text-tv-muted">평가손익 <b className={`font-mono ${pnlCls(o?.pnl ?? p.pnl)}`}>{signed(o?.pnl ?? p.pnl)}</b></span>
<span className="text-tv-muted">경과 <b className="text-tv-text font-mono">{el != null ? `${Math.round(el)}분` : '-'}</b>
{tsMin ? ` / 시간손절 ${tsMin}분` : ''}</span>
<span className="text-tv-muted">
트레일링 {o?.trailing_active
? <b className="text-tv-warn font-mono">{fmtPrice(o.trailing_stop)}</b>
: <b className="text-tv-text">{o ? '미발동' : '-'}</b>}
</span>
</div>
{tsPct != null && (
<div className="mt-1 h-1 rounded bg-tv-raised overflow-hidden" title="시간손절까지 경과 비율 (수익률이 기준 미만일 때만 청산)">
<div className={`h-full ${tsPct >= 100 ? 'bg-tv-warn' : 'bg-tv-muted'}`} style={{ width: `${tsPct}%` }} />
</div>
)}
</div>
)
})}
</div>
</div>
)
})
DtPositionCards.displayName = 'DtPositionCards'