"use client"; import { useState } from "react"; import { api, type LatestPredictionResponse, type LatestPredictionStep, type PredictResponse, } from "../lib/api"; type Props = { code: string; initial?: LatestPredictionResponse | null; onResult: (pred: LatestPredictionResponse) => void; }; function normalizeFromPredictResponse( code: string, resp: PredictResponse, ): LatestPredictionResponse { const steps: LatestPredictionStep[] = resp.steps.map((s) => ({ predicted_at: null, target_date: s.target_date ?? "", horizon: s.horizon, direction: s.direction, prob_up: s.prob_up, prob_flat: s.prob_flat, prob_down: s.prob_down, expected_return: s.expected_return, point_close: s.point_close, ci_low: s.ci_low, ci_high: s.ci_high, user_triggered: resp.user_triggered, features_snapshot: null, })); return { code, found: true, base_date: resp.base_date, base_close: resp.base_close, steps, }; } // 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(initial ?? null); const [loading, setLoading] = useState(false); const [err, setErr] = useState(null); const [presetIdx, setPresetIdx] = useState(1); // 30일 기본 async function runPredict() { setLoading(true); setErr(null); try { const horizons = PREDICT_PRESETS[presetIdx].horizons; const r = await api.predict(code, horizons); const normalized = normalizeFromPredictResponse(code, r); setPred(normalized); onResult(normalized); } catch (e) { setErr(e instanceof Error ? e.message : String(e)); } finally { setLoading(false); } } const steps = pred?.steps ?? []; return (
예측 (Chronos + LightGBM 앙상블)
결과는 차트에 옅은 색 캔들로 이어 붙고, 꼬리 길이가 신뢰구간 폭입니다.
예측 기간: {PREDICT_PRESETS.map((p, i) => { const on = presetIdx === i; return ( // segmented control — PeriodTabs / InvestorCumulative window / SMA·RSI·MACD 토글과 // 같은 패턴. type="button" 으로 폼 submit 기본값 사고 차단, aria-pressed 로 스크린 // 리더에 현재 선택 노출. 시각 토큰은 PredictionPanel 자체 톤(bg-zinc-700 solid) // 유지 — 본문 카드 영역 안 primary 보조 컨트롤이라 툴바용 zinc-800/80 보다 한 // 단계 도드라지는 게 의도. 패딩(px-3 py-1) 도 옆 rose-600 실행 버튼과 시각 // 무게 균형을 위해 툴바 토글(px-2 py-0.5) 보다 큼. ); })} {presetIdx === 2 && ( ※ 30거래일 너머는 모델 학습 범위 밖 — 참고용 )}
{err &&
에러: {err}
} {pred?.found ? (
기준일 {pred.base_date} · 기준종가{" "} {pred.base_close != null ? pred.base_close.toLocaleString() : "-"}
{steps.map((s) => ( ))}
+거래일 매칭일 방향 P(up/flat/down) 기대수익 예측 종가 q10~q90
+{s.horizon} {s.target_date} {s.direction} {fmtPct(s.prob_up)} / {fmtPct(s.prob_flat)} / {fmtPct(s.prob_down)} {fmtSignedPct(s.expected_return)} {s.point_close != null ? s.point_close.toLocaleString() : "-"} {s.ci_low != null ? s.ci_low.toLocaleString() : "-"} ~{" "} {s.ci_high != null ? s.ci_high.toLocaleString() : "-"}
) : (
아직 예측이 없습니다. 예상차트 보기 를 누르면 선택한 기간의 예측 캔들이 현재 차트에 옅은 색으로 이어 붙습니다.
)}
); } function fmtPct(v: number | null): string { if (v == null) return "-"; return `${(v * 100).toFixed(0)}%`; } function fmtSignedPct(v: number | null): string { if (v == null) return "-"; const pct = v * 100; const sign = pct >= 0 ? "+" : ""; return `${sign}${pct.toFixed(2)}%`; }