feat(ui): 예측 오버레이 캔들화 + 7탭 기간 셀렉터 + 가격 헤로
- 예측 오버레이: 라인 시리즈 3개 (median/q10/q90) 를 단일 캔들 시리즈 하나로 합쳤다. 합성 OHLC: open=직전 close, close=point_close, high=ci_high, low=ci_low. 옅은 색 (up #fda4af / down #93c5fd) 으로 실 캔들과 시각 톤은 같고 명도만 낮춰 "어우러지되 미래"임이 보이게. - 예측 프리셋 단순화: 단기/중기/장기 + 직접입력 다 떼고 15일/30일/1년 3개로. 백엔드 horizon cap 30 → 252 로 확장 (1년 = 240 거래일). - PriceHero: 종목명/현재가/등락 큰 글씨 헤더. KR 관례대로 상승=빨강, 하락=파랑. - PeriodTabs: 1일/1주/1달/3달/1년/5년/최대 7탭. 기존 interval+days 이중 컨트롤 제거. 5년·최대 는 자동으로 주봉/월봉으로 폴백. - 차트 본체 캔들 색조도 KR 관례 (적/청) 로 통일. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,6 @@ import {
|
||||
type CandlestickData,
|
||||
type IChartApi,
|
||||
type ISeriesApi,
|
||||
type LineData,
|
||||
type UTCTimestamp,
|
||||
} from "lightweight-charts";
|
||||
import type { ChartPayload, LatestPredictionResponse } from "../lib/api";
|
||||
@@ -16,10 +15,9 @@ type Props = {
|
||||
prediction?: LatestPredictionResponse | null;
|
||||
};
|
||||
|
||||
// 'YYYY-MM-DD' 또는 'YYYY-MM-DDTHH:MM:SS' (KST naive, 백엔드가 +09:00 시각의 wall-clock 을
|
||||
// 그대로 ISO 로 직렬화) 를 UTCTimestamp 로. lightweight-charts 는 timestamp 가 UTC 라고
|
||||
// 가정하지만, 우리는 KST wall-clock 을 UTC 인 척 넣는다 — timeScale 의 표시도 KST 그대로
|
||||
// 나와서 한국 사용자에겐 가장 직관적.
|
||||
// KST naive ISO ('YYYY-MM-DD' 또는 'YYYY-MM-DDTHH:MM:SS') 를 UTCTimestamp 로.
|
||||
// lightweight-charts 는 UTC 라고 가정하지만 KST wall-clock 을 UTC 처럼 넣어
|
||||
// timeScale 표시가 한국 사용자에게 자연스럽게 KST 그대로 나오도록 함.
|
||||
function isoToUtcTs(s: string): UTCTimestamp {
|
||||
if (s.length <= 10) {
|
||||
return (Date.UTC(
|
||||
@@ -28,7 +26,6 @@ function isoToUtcTs(s: string): UTCTimestamp {
|
||||
Number(s.slice(8, 10)),
|
||||
) / 1000) as UTCTimestamp;
|
||||
}
|
||||
// datetime: YYYY-MM-DDTHH:MM:SS
|
||||
return (Date.UTC(
|
||||
Number(s.slice(0, 4)),
|
||||
Number(s.slice(5, 7)) - 1,
|
||||
@@ -39,17 +36,22 @@ function isoToUtcTs(s: string): UTCTimestamp {
|
||||
) / 1000) as UTCTimestamp;
|
||||
}
|
||||
|
||||
// 한국 거래소 관행: 상승=빨강, 하락=파랑.
|
||||
const COLOR_UP = "#ef4444";
|
||||
const COLOR_DOWN = "#3b82f6";
|
||||
// 예측 캔들은 같은 up/down 색 옅은 톤. "어우러지되 미래 데이터임이 명확" 한 시각 신호.
|
||||
const PRED_UP = "#fda4af";
|
||||
const PRED_DOWN = "#93c5fd";
|
||||
|
||||
export function StockChart({ chart, prediction }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
const candleRef = useRef<ISeriesApi<"Candlestick"> | null>(null);
|
||||
const predRef = useRef<ISeriesApi<"Line"> | null>(null);
|
||||
const predLowRef = useRef<ISeriesApi<"Line"> | null>(null);
|
||||
const predHighRef = useRef<ISeriesApi<"Line"> | null>(null);
|
||||
const predRef = useRef<ISeriesApi<"Candlestick"> | null>(null);
|
||||
|
||||
const isIntraday = chart.interval === "10m";
|
||||
|
||||
// create chart once (interval 바뀌면 timeVisible 토글 위해 의존성에 isIntraday 포함 — 재생성)
|
||||
// chart 생성 — interval 바뀌면 timeVisible 토글을 위해 재생성.
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const c = createChart(containerRef.current, {
|
||||
@@ -68,14 +70,15 @@ export function StockChart({ chart, prediction }: Props) {
|
||||
secondsVisible: false,
|
||||
},
|
||||
autoSize: true,
|
||||
crosshair: { mode: 1 },
|
||||
});
|
||||
const candle = c.addCandlestickSeries({
|
||||
upColor: "#22c55e",
|
||||
downColor: "#ef4444",
|
||||
borderUpColor: "#22c55e",
|
||||
borderDownColor: "#ef4444",
|
||||
wickUpColor: "#22c55e",
|
||||
wickDownColor: "#ef4444",
|
||||
upColor: COLOR_UP,
|
||||
downColor: COLOR_DOWN,
|
||||
borderUpColor: COLOR_UP,
|
||||
borderDownColor: COLOR_DOWN,
|
||||
wickUpColor: COLOR_UP,
|
||||
wickDownColor: COLOR_DOWN,
|
||||
});
|
||||
chartRef.current = c;
|
||||
candleRef.current = candle;
|
||||
@@ -84,12 +87,10 @@ export function StockChart({ chart, prediction }: Props) {
|
||||
chartRef.current = null;
|
||||
candleRef.current = null;
|
||||
predRef.current = null;
|
||||
predLowRef.current = null;
|
||||
predHighRef.current = null;
|
||||
};
|
||||
}, [isIntraday]);
|
||||
|
||||
// push candle data + today marker
|
||||
// 실 데이터 push.
|
||||
useEffect(() => {
|
||||
if (!candleRef.current) return;
|
||||
const data: CandlestickData[] = chart.ohlcv
|
||||
@@ -102,107 +103,80 @@ export function StockChart({ chart, prediction }: Props) {
|
||||
close: p.close as number,
|
||||
}));
|
||||
candleRef.current.setData(data);
|
||||
// 오늘 표시는 차트 본체 위가 아니라 컨테이너 아래 캡션 (return JSX) 으로 옮김.
|
||||
// lightweight-charts 의 timeScale tick 자체에 라벨을 끼울 공식 API 가 없어서,
|
||||
// 시각적으로 동일한 위치 (시간축 바로 아래) 에 별도 div 로 렌더.
|
||||
chartRef.current?.timeScale().fitContent();
|
||||
}, [chart, isIntraday]);
|
||||
|
||||
// push prediction overlay (10분봉에서는 표시 안 함 — 예측은 일봉 기준)
|
||||
// 예측 오버레이를 캔들 시리즈로 렌더.
|
||||
// 합성 OHLC:
|
||||
// open[i] = i==0 ? base_close : prev.point_close
|
||||
// close[i] = point_close
|
||||
// high[i] = max(ci_high, open, close)
|
||||
// low[i] = min(ci_low, open, close)
|
||||
// → 몸통 = 기간 사이 예상 변동, 위/아래 꼬리 = 신뢰구간 폭 (불확실성).
|
||||
// 10분봉(intraday) 에서는 예측 표시 안 함 — 모델은 일봉 기반.
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return;
|
||||
if (predRef.current) {
|
||||
chartRef.current.removeSeries(predRef.current);
|
||||
predRef.current = null;
|
||||
}
|
||||
if (predLowRef.current) {
|
||||
chartRef.current.removeSeries(predLowRef.current);
|
||||
predLowRef.current = null;
|
||||
}
|
||||
if (predHighRef.current) {
|
||||
chartRef.current.removeSeries(predHighRef.current);
|
||||
predHighRef.current = null;
|
||||
}
|
||||
if (isIntraday) return;
|
||||
if (!prediction || !prediction.found || !prediction.steps?.length) return;
|
||||
const baseDate = prediction.base_date!;
|
||||
if (!prediction?.found || !prediction.steps?.length) return;
|
||||
const baseDate = prediction.base_date;
|
||||
const baseClose = prediction.base_close;
|
||||
if (!baseClose) return;
|
||||
const sorted = [...prediction.steps].sort((a, b) => a.horizon - b.horizon);
|
||||
if (!baseDate || baseClose == null) return;
|
||||
|
||||
const med: LineData[] = [
|
||||
{ time: isoToUtcTs(baseDate), value: baseClose },
|
||||
...sorted
|
||||
.filter((s) => s.point_close !== null)
|
||||
.map((s) => ({ time: isoToUtcTs(s.target_date), value: s.point_close as number })),
|
||||
];
|
||||
const lo: LineData[] = [
|
||||
{ time: isoToUtcTs(baseDate), value: baseClose },
|
||||
...sorted
|
||||
.filter((s) => s.ci_low !== null)
|
||||
.map((s) => ({ time: isoToUtcTs(s.target_date), value: s.ci_low as number })),
|
||||
];
|
||||
const hi: LineData[] = [
|
||||
{ time: isoToUtcTs(baseDate), value: baseClose },
|
||||
...sorted
|
||||
.filter((s) => s.ci_high !== null)
|
||||
.map((s) => ({ time: isoToUtcTs(s.target_date), value: s.ci_high as number })),
|
||||
];
|
||||
const sorted = [...prediction.steps]
|
||||
.filter(
|
||||
(s) =>
|
||||
s.point_close != null && s.ci_low != null && s.ci_high != null && s.target_date,
|
||||
)
|
||||
.sort((a, b) => a.horizon - b.horizon);
|
||||
if (!sorted.length) return;
|
||||
|
||||
const medLine = chartRef.current.addLineSeries({
|
||||
color: "#a78bfa",
|
||||
lineWidth: 2,
|
||||
lineStyle: 2, // dashed
|
||||
const data: CandlestickData[] = [];
|
||||
let prevClose = baseClose;
|
||||
for (const s of sorted) {
|
||||
const open = prevClose;
|
||||
const close = s.point_close as number;
|
||||
const ciHi = s.ci_high as number;
|
||||
const ciLo = s.ci_low as number;
|
||||
data.push({
|
||||
time: isoToUtcTs(s.target_date),
|
||||
open,
|
||||
close,
|
||||
high: Math.max(ciHi, open, close),
|
||||
low: Math.min(ciLo, open, close),
|
||||
});
|
||||
prevClose = close;
|
||||
}
|
||||
|
||||
const pred = chartRef.current.addCandlestickSeries({
|
||||
upColor: PRED_UP,
|
||||
downColor: PRED_DOWN,
|
||||
borderUpColor: PRED_UP,
|
||||
borderDownColor: PRED_DOWN,
|
||||
wickUpColor: PRED_UP,
|
||||
wickDownColor: PRED_DOWN,
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: true,
|
||||
title: "예측 median",
|
||||
title: "예측",
|
||||
});
|
||||
medLine.setData(med);
|
||||
const loLine = chartRef.current.addLineSeries({
|
||||
color: "#7c3aed",
|
||||
lineWidth: 1,
|
||||
lineStyle: 1,
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: false,
|
||||
title: "q10",
|
||||
});
|
||||
loLine.setData(lo);
|
||||
const hiLine = chartRef.current.addLineSeries({
|
||||
color: "#7c3aed",
|
||||
lineWidth: 1,
|
||||
lineStyle: 1,
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: false,
|
||||
title: "q90",
|
||||
});
|
||||
hiLine.setData(hi);
|
||||
predRef.current = medLine;
|
||||
predLowRef.current = loLine;
|
||||
predHighRef.current = hiLine;
|
||||
pred.setData(data);
|
||||
predRef.current = pred;
|
||||
chartRef.current.timeScale().fitContent();
|
||||
}, [prediction, isIntraday]);
|
||||
|
||||
// 오늘 라벨 — 차트 본체에 마커 대신 시간축 바로 아래에 작은 캡션으로.
|
||||
// 10분봉은 데이터 자체가 오늘 하루라 굳이 라벨 불필요.
|
||||
const todayLabel =
|
||||
!isIntraday && chart.today
|
||||
? new Date(chart.today + "T00:00:00").toLocaleDateString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
weekday: "short",
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-md border border-zinc-800 bg-zinc-900/30 p-2">
|
||||
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900/30 p-2">
|
||||
<div className="h-[460px] w-full">
|
||||
<div ref={containerRef} className="h-full w-full" />
|
||||
</div>
|
||||
{todayLabel && (
|
||||
<div className="mt-1 flex items-center justify-end gap-2 px-2 text-xs text-zinc-400">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-amber-400" />
|
||||
<span>오늘 · {todayLabel}</span>
|
||||
{prediction?.found && !isIntraday && (
|
||||
<div className="mt-1 flex items-center justify-end gap-2 px-2 text-[11px] text-zinc-500">
|
||||
<span className="inline-block h-2 w-2 rounded-sm bg-rose-300" />
|
||||
<span className="inline-block h-2 w-2 rounded-sm bg-sky-300" />
|
||||
<span>예측 (꼬리 = q10~q90 신뢰구간)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user