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:
@@ -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>
|
||||
|
||||
@@ -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&G, 한국가스공사
|
||||
삼성전자, SK하이닉스, 에코프로비엠, 한미반도체, 두산에너빌리티,
|
||||
한화에어로스페이스, HD현대중공업, NAVER, KT&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>
|
||||
|
||||
52
web/components/PeriodTabs.tsx
Normal file
52
web/components/PeriodTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
52
web/components/PriceHero.tsx
Normal file
52
web/components/PriceHero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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