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:
Claude
2026-05-28 01:05:23 +09:00
parent bf898d78be
commit b16f2b578d
7 changed files with 306 additions and 292 deletions

View File

@@ -1,42 +1,25 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { MetricsPanel } from "../../components/MetricsPanel";
import { NewsList } from "../../components/NewsList";
import { PeriodTabs, periodSpec, type Period } from "../../components/PeriodTabs";
import { PredictionPanel } from "../../components/PredictionPanel";
import { PriceHero } from "../../components/PriceHero";
import { StockChart } from "../../components/StockChart";
import {
api,
type ChartInterval,
type ChartPayload,
type LatestPredictionResponse,
} from "../../lib/api";
const INTERVALS: { label: string; value: ChartInterval; defaultDays: number }[] = [
{ label: "10분", value: "10m", defaultDays: 1 },
{ label: "일", value: "1d", defaultDays: 180 },
{ label: "주", value: "1w", defaultDays: 365 * 2 },
{ label: "월", value: "1mo", defaultDays: 365 * 5 },
];
import { api, type ChartPayload, type LatestPredictionResponse } from "../../lib/api";
export default function CodePage({ params }: { params: { code: string } }) {
const { code } = params;
const [chart, setChart] = useState<ChartPayload | null>(null);
const [prediction, setPrediction] = useState<LatestPredictionResponse | null>(null);
const [err, setErr] = useState<string | null>(null);
const [interval, setIntervalKind] = useState<ChartInterval>("1d");
const [days, setDays] = useState(180);
const [period, setPeriod] = useState<Period>("3M");
// interval 바꾸면 days 도 그 interval 에 맞는 기본값으로 (사용자가 명시적으로 다시 고를 수 있게).
function pickInterval(v: ChartInterval) {
const meta = INTERVALS.find((i) => i.value === v)!;
setIntervalKind(v);
setDays(meta.defaultDays);
}
const spec = periodSpec(period);
const isIntraday = spec.interval === "10m";
// 초기/주기적 차트 로드. 10분봉이면 60초마다 폴링 — 백엔드가 캐시-then-fetch 로
// 10분 이내면 DB 만 읽고, 넘었으면 KIS 호출. 폴링 부담은 낮음.
useEffect(() => {
let alive = true;
setErr(null);
@@ -44,7 +27,7 @@ export default function CodePage({ params }: { params: { code: string } }) {
const load = () => {
api
.getChart(code, days, interval)
.getChart(code, spec.days, spec.interval)
.then((c) => {
if (alive) setChart(c);
})
@@ -54,7 +37,8 @@ export default function CodePage({ params }: { params: { code: string } }) {
};
load();
if (interval === "10m") {
// 1일(10분봉) 모드만 60s 폴링 — 백엔드가 10분 내면 DB 만 읽음.
if (isIntraday) {
const h = window.setInterval(load, 60_000);
return () => {
alive = false;
@@ -64,7 +48,7 @@ export default function CodePage({ params }: { params: { code: string } }) {
return () => {
alive = false;
};
}, [code, days, interval]);
}, [code, spec.days, spec.interval, isIntraday]);
useEffect(() => {
let alive = true;
@@ -74,76 +58,54 @@ export default function CodePage({ params }: { params: { code: string } }) {
if (alive && r.found) setPrediction(r);
})
.catch(() => {
// 예측 이력 없는 경우는 무시.
// 예측 이력 없음 — 무시
});
return () => {
alive = false;
};
}, [code]);
return (
<main className="mx-auto max-w-5xl px-6 py-10">
<div className="mb-4 flex items-center justify-between">
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
</Link>
<div className="flex items-center gap-2">
<div className="flex overflow-hidden rounded-md border border-zinc-700 text-xs">
{INTERVALS.map((it) => (
<button
key={it.value}
onClick={() => pickInterval(it.value)}
className={
interval === it.value
? "bg-emerald-700 px-3 py-1 text-white"
: "bg-zinc-900 px-3 py-1 text-zinc-300 hover:bg-zinc-800"
}
>
{it.label}
</button>
))}
</div>
{interval !== "10m" && (
<select
value={days}
onChange={(e) => setDays(Number(e.target.value))}
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs"
>
<option value={60}> 3</option>
<option value={180}> 6</option>
<option value={365}> 1</option>
<option value={365 * 2}> 2</option>
<option value={365 * 5}> 5</option>
<option value={365 * 10}> 10</option>
</select>
)}
</div>
</div>
// 현재가/전일가 — ohlcv 끝 두 점에서 산출.
const { current, prev, asOf } = useMemo(() => {
if (!chart || !chart.ohlcv.length) return { current: null, prev: null, asOf: null };
const valid = chart.ohlcv.filter((p) => p.close != null);
if (!valid.length) return { current: null, prev: null, asOf: null };
const last = valid[valid.length - 1];
const prevPt = valid.length >= 2 ? valid[valid.length - 2] : null;
return {
current: last.close,
prev: prevPt?.close ?? null,
asOf: last.date,
};
}, [chart]);
{chart && (
<div className="mb-4 flex items-baseline justify-between">
<h1 className="text-2xl font-semibold text-zinc-100">
{chart.name}{" "}
<span className="text-sm font-normal text-zinc-500">
{chart.code} · {chart.market}
</span>
</h1>
{interval === "10m" && (
<div className="text-xs text-zinc-500">
10 · 60
{chart.intraday_status && (
<span className="ml-2 text-zinc-600">[{chart.intraday_status}]</span>
)}
</div>
)}
</div>
)}
return (
<main className="mx-auto max-w-5xl px-6 py-8">
<div className="mb-4 flex items-center justify-between gap-3">
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
</Link>
<PeriodTabs value={period} onChange={(id) => setPeriod(id)} />
</div>
{err && <div className="mb-4 text-sm text-red-400"> : {err}</div>}
{chart && (
<>
<PriceHero
name={chart.name}
code={chart.code}
market={chart.market}
current={current}
prev={prev}
asOf={asOf}
/>
<StockChart chart={chart} prediction={prediction} />
{isIntraday && chart.intraday_status && (
<div className="mt-2 text-right text-[11px] text-zinc-500">
10 · 60 · [{chart.intraday_status}]
</div>
)}
<div className="mt-6">
<PredictionPanel code={code} initial={prediction} onResult={setPrediction} />
</div>

View File

@@ -3,10 +3,12 @@ import { SearchBox } from "../components/SearchBox";
export default function HomePage() {
return (
<main className="mx-auto max-w-3xl px-6 py-16">
<h1 className="text-3xl font-bold tracking-tight">Stock Chart Site</h1>
<h1 className="text-3xl font-bold tracking-tight text-zinc-50">
</h1>
<p className="mt-2 text-sm text-zinc-400">
, <b> </b> Chronos + LightGBM
(1·3·5) .
, <b className="text-rose-300"> </b>
15·30·1 .
</p>
<div className="mt-8">
@@ -14,17 +16,18 @@ export default function HomePage() {
</div>
<div className="mt-12 grid gap-3 text-xs text-zinc-500 sm:grid-cols-2">
<div className="rounded-md border border-zinc-800 bg-zinc-900/30 p-4">
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4">
<div className="font-medium text-zinc-300"> 10</div>
<div className="mt-1">
, SK하이닉스, , , , ,
HD현대중공업, NAVER, KT&amp;G,
, SK하이닉스, , , ,
, HD현대중공업, NAVER, KT&amp;G,
</div>
</div>
<div className="rounded-md border border-zinc-800 bg-zinc-900/30 p-4">
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4">
<div className="font-medium text-zinc-300">/</div>
<div className="mt-1">
16:30 KST , 02:00 KST LGBM .
16:30 KST , 02:00 KST LGBM .
Chronos(zero-shot) + LightGBM.
</div>
</div>
</div>

View File

@@ -0,0 +1,52 @@
"use client";
import type { ChartInterval } from "../lib/api";
// 7-탭 기간 셀렉터. 각 탭이 (interval, days) 쌍으로 풀린다.
// 1D 만 10분봉, 5Y/MAX 는 자동으로 주봉/월봉 사용 (포인트 수 폭주 방지).
export type Period = "1D" | "1W" | "1M" | "3M" | "1Y" | "5Y" | "ALL";
type PeriodSpec = { id: Period; label: string; interval: ChartInterval; days: number };
const PERIODS: PeriodSpec[] = [
{ id: "1D", label: "1일", interval: "10m", days: 1 },
{ id: "1W", label: "1주", interval: "1d", days: 7 },
{ id: "1M", label: "1달", interval: "1d", days: 30 },
{ id: "3M", label: "3달", interval: "1d", days: 90 },
{ id: "1Y", label: "1년", interval: "1d", days: 365 },
{ id: "5Y", label: "5년", interval: "1w", days: 365 * 5 },
{ id: "ALL", label: "최대", interval: "1mo", days: 365 * 20 },
];
export function periodSpec(id: Period): PeriodSpec {
return PERIODS.find((p) => p.id === id) ?? PERIODS[2];
}
type Props = {
value: Period;
onChange: (id: Period, interval: ChartInterval, days: number) => void;
};
export function PeriodTabs({ value, onChange }: Props) {
return (
<div className="inline-flex rounded-full border border-zinc-800 bg-zinc-900 p-1 text-xs">
{PERIODS.map((p) => {
const active = value === p.id;
return (
<button
key={p.id}
onClick={() => onChange(p.id, p.interval, p.days)}
className={
active
? "rounded-full bg-zinc-700 px-3 py-1 font-medium text-zinc-50"
: "rounded-full px-3 py-1 text-zinc-400 hover:text-zinc-100"
}
>
{p.label}
</button>
);
})}
</div>
);
}

View File

@@ -42,38 +42,25 @@ function normalizeFromPredictResponse(
};
}
const HORIZON_PRESETS: { label: string; value: number[] }[] = [
{ label: "단기 (1·3·5)", value: [1, 3, 5] },
{ label: "중기 (1·5·10)", value: [1, 5, 10] },
{ label: "장기 (5·10·20)", value: [5, 10, 20] },
// 15일/30일/1년 세 프리셋. 각각 그 구간 안에 6~7 step 을 깔아서 차트 캔들이
// 너무 듬성하지 않게 한다. 백엔드는 1..252 거래일 horizon 을 허용 (predict.py 참조).
const PREDICT_PRESETS: { id: string; label: string; horizons: number[] }[] = [
{ id: "15d", label: "15일", horizons: [1, 3, 5, 7, 10, 15] },
{ id: "30d", label: "30일", horizons: [1, 5, 10, 15, 20, 25, 30] },
{ id: "1y", label: "1년", horizons: [5, 15, 30, 60, 120, 180, 240] },
];
export function PredictionPanel({ code, initial, onResult }: Props) {
const [pred, setPred] = useState<LatestPredictionResponse | null>(initial ?? null);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [presetIdx, setPresetIdx] = useState(0);
const [customRaw, setCustomRaw] = useState("");
const [useCustom, setUseCustom] = useState(false);
function effectiveHorizons(): number[] {
if (useCustom) {
// 백엔드 predict.py 가 1~30 만 허용 (모델 학습/검증 범위와 일치).
// 프론트에서 동일 cap 으로 끊어서 400 안 나게 한다.
const parsed = customRaw
.split(",")
.map((s) => Number(s.trim()))
.filter((n) => Number.isFinite(n) && n >= 1 && n <= 30);
if (parsed.length > 0) return Array.from(new Set(parsed)).sort((a, b) => a - b);
}
return HORIZON_PRESETS[presetIdx].value;
}
const [presetIdx, setPresetIdx] = useState(1); // 30일 기본
async function runPredict() {
setLoading(true);
setErr(null);
try {
const horizons = effectiveHorizons();
const horizons = PREDICT_PRESETS[presetIdx].horizons;
const r = await api.predict(code, horizons);
const normalized = normalizeFromPredictResponse(code, r);
setPred(normalized);
@@ -88,64 +75,43 @@ export function PredictionPanel({ code, initial, onResult }: Props) {
const steps = pred?.steps ?? [];
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
<div className="mb-3 flex items-center justify-between">
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-zinc-200"> (Chronos + LightGBM )</div>
<div className="text-xs text-zinc-500">
.
, .
</div>
</div>
<button
onClick={runPredict}
disabled={loading}
className="rounded-md bg-emerald-700 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-600 disabled:opacity-50"
className="rounded-md bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-500 disabled:opacity-50"
>
{loading ? "예측 중…" : pred?.found ? "다시 예측" : "예상차트 보기"}
</button>
</div>
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
<span className="text-zinc-500"> :</span>
{HORIZON_PRESETS.map((p, i) => (
<span className="text-zinc-500"> :</span>
{PREDICT_PRESETS.map((p, i) => (
<button
key={p.label}
onClick={() => {
setUseCustom(false);
setPresetIdx(i);
}}
key={p.id}
onClick={() => setPresetIdx(i)}
className={
!useCustom && presetIdx === i
? "rounded-full bg-emerald-700 px-3 py-1 text-white"
presetIdx === i
? "rounded-full bg-zinc-700 px-3 py-1 font-medium text-zinc-50"
: "rounded-full border border-zinc-700 px-3 py-1 text-zinc-300 hover:border-zinc-500"
}
>
{p.label}
</button>
))}
<label
className={
useCustom
? "flex items-center gap-1 rounded-full bg-emerald-700 px-3 py-1 text-white"
: "flex items-center gap-1 rounded-full border border-zinc-700 px-3 py-1 text-zinc-300 hover:border-zinc-500"
}
>
<input
type="checkbox"
checked={useCustom}
onChange={(e) => setUseCustom(e.target.checked)}
className="h-3 w-3"
/>
<input
type="text"
placeholder="1~30, 예: 1,2,3,7"
value={customRaw}
onChange={(e) => setCustomRaw(e.target.value)}
onFocus={() => setUseCustom(true)}
className="ml-1 w-28 rounded border border-zinc-700 bg-zinc-900 px-1 py-0.5 text-xs text-zinc-100"
/>
</label>
{presetIdx === 2 && (
<span className="ml-1 text-[11px] text-amber-400">
30
</span>
)}
</div>
{err && <div className="mb-3 text-xs text-red-400">: {err}</div>}
@@ -156,54 +122,56 @@ export function PredictionPanel({ code, initial, onResult }: Props) {
{pred.base_date} · {" "}
{pred.base_close != null ? pred.base_close.toLocaleString() : "-"}
</div>
<table className="w-full text-left text-sm">
<thead className="text-xs text-zinc-500">
<tr>
<th className="py-1">+</th>
<th></th>
<th></th>
<th>P(up/flat/down)</th>
<th></th>
<th> </th>
<th>q10~q90</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{steps.map((s) => (
<tr key={s.horizon}>
<td className="py-2">+{s.horizon}</td>
<td className="text-xs text-zinc-400">{s.target_date}</td>
<td>
<span
className={
s.direction === "up"
? "text-emerald-400"
: s.direction === "down"
? "text-red-400"
: "text-zinc-300"
}
>
{s.direction}
</span>
</td>
<td className="text-xs text-zinc-300">
{fmtPct(s.prob_up)} / {fmtPct(s.prob_flat)} / {fmtPct(s.prob_down)}
</td>
<td>{fmtSignedPct(s.expected_return)}</td>
<td>{s.point_close != null ? s.point_close.toLocaleString() : "-"}</td>
<td className="text-xs text-zinc-400">
{s.ci_low != null ? s.ci_low.toLocaleString() : "-"} ~{" "}
{s.ci_high != null ? s.ci_high.toLocaleString() : "-"}
</td>
<div className="max-h-60 overflow-y-auto">
<table className="w-full text-left text-sm">
<thead className="sticky top-0 bg-zinc-900/95 text-xs text-zinc-500">
<tr>
<th className="py-1">+</th>
<th></th>
<th></th>
<th>P(up/flat/down)</th>
<th></th>
<th> </th>
<th>q10~q90</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody className="divide-y divide-zinc-800">
{steps.map((s) => (
<tr key={s.horizon}>
<td className="py-2">+{s.horizon}</td>
<td className="text-xs text-zinc-400">{s.target_date}</td>
<td>
<span
className={
s.direction === "up"
? "text-rose-400"
: s.direction === "down"
? "text-sky-400"
: "text-zinc-300"
}
>
{s.direction}
</span>
</td>
<td className="text-xs text-zinc-300">
{fmtPct(s.prob_up)} / {fmtPct(s.prob_flat)} / {fmtPct(s.prob_down)}
</td>
<td>{fmtSignedPct(s.expected_return)}</td>
<td>{s.point_close != null ? s.point_close.toLocaleString() : "-"}</td>
<td className="text-xs text-zinc-400">
{s.ci_low != null ? s.ci_low.toLocaleString() : "-"} ~{" "}
{s.ci_high != null ? s.ci_high.toLocaleString() : "-"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<div className="text-xs text-zinc-500">
. <b> </b> 1·3·5
.
. <b> </b>
.
</div>
)}
</div>

View File

@@ -0,0 +1,52 @@
"use client";
// 종목 페이지 상단 가격 헤더.
// 표시: 종목명 / (코드·시장) / 현재가 / 전일대비 (절대값 + 퍼센트).
// 한국 거래소 관행대로 상승=빨강, 하락=파랑.
type Props = {
name: string;
code: string;
market: string;
current: number | null;
prev: number | null;
asOf?: string | null;
};
export function PriceHero({ name, code, market, current, prev, asOf }: Props) {
const change = current != null && prev != null ? current - prev : null;
const pct = change != null && prev ? (change / prev) * 100 : null;
const tone =
change == null || change === 0
? "text-zinc-400"
: change > 0
? "text-rose-400"
: "text-sky-400";
const arrow = change == null || change === 0 ? "—" : change > 0 ? "▲" : "▼";
return (
<div className="mb-5">
<div className="text-xs text-zinc-500">
{code} · {market}
</div>
<h1 className="mt-0.5 text-2xl font-bold tracking-tight text-zinc-50">
{name}
</h1>
<div className="mt-3 flex items-baseline gap-3">
<span className="text-4xl font-semibold tabular-nums text-zinc-50">
{current != null ? current.toLocaleString() : "-"}
</span>
{change != null && pct != null && (
<span className={`text-base font-medium tabular-nums ${tone}`}>
{arrow} {Math.abs(change).toLocaleString()}{" "}
({pct >= 0 ? "+" : ""}
{pct.toFixed(2)}%)
</span>
)}
</div>
{asOf && (
<div className="mt-1 text-xs text-zinc-500"> {asOf}</div>
)}
</div>
);
}

View File

@@ -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>