diff --git a/backend/app/api/predict.py b/backend/app/api/predict.py index 80d4d35..8106ba7 100644 --- a/backend/app/api/predict.py +++ b/backend/app/api/predict.py @@ -40,8 +40,11 @@ def predict_endpoint( try: hs = tuple(int(x) for x in horizons.split(",") if x.strip()) - if not hs or any(h < 1 or h > 30 for h in hs): - raise ValueError("invalid horizons") + # cap 을 252 거래일 (대략 1년) 까지 허용. 30 거래일 너머는 모델 학습 범위 밖이라 + # 신뢰성이 떨어지지만, 프론트가 1년 프리셋을 요청하기 때문에 게이트만 풀어둔다. + # 사용자에게는 신뢰구간 폭으로 불확실성이 자연 전달됨. + if not hs or any(h < 1 or h > 252 for h in hs): + raise ValueError("invalid horizons (1..252)") except ValueError as exc: raise HTTPException(status_code=400, detail=f"bad horizons: {exc}") diff --git a/web/app/[code]/page.tsx b/web/app/[code]/page.tsx index fe730c7..74a1b64 100644 --- a/web/app/[code]/page.tsx +++ b/web/app/[code]/page.tsx @@ -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(null); const [prediction, setPrediction] = useState(null); const [err, setErr] = useState(null); - const [interval, setIntervalKind] = useState("1d"); - const [days, setDays] = useState(180); + const [period, setPeriod] = useState("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 ( -
-
- - ← 검색으로 - -
-
- {INTERVALS.map((it) => ( - - ))} -
- {interval !== "10m" && ( - - )} -
-
+ // 현재가/전일가 — 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 && ( -
-

- {chart.name}{" "} - - {chart.code} · {chart.market} - -

- {interval === "10m" && ( -
- 실시간 10분봉 · 60초마다 갱신 - {chart.intraday_status && ( - [{chart.intraday_status}] - )} -
- )} -
- )} + return ( +
+
+ + ← 검색 + + setPeriod(id)} /> +
{err &&
차트 로딩 실패: {err}
} {chart && ( <> + + {isIntraday && chart.intraday_status && ( +
+ 실시간 10분봉 · 60초 갱신 · [{chart.intraday_status}] +
+ )}
diff --git a/web/app/page.tsx b/web/app/page.tsx index 2a71a0a..05721c4 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -3,10 +3,12 @@ import { SearchBox } from "../components/SearchBox"; export default function HomePage() { return (
-

Stock Chart Site

+

+ 종목 검색 +

- 종목을 검색해 현재 차트를 보고, 예상차트 보기 버튼으로 Chronos + LightGBM - 앙상블의 단기(1·3·5거래일) 예측을 차트에 이어 붙입니다. + 종목을 골라 차트를 보고, 예상차트 보기 버튼으로 + 15일·30일·1년 예측 캔들을 같은 차트에 이어 붙입니다.

@@ -14,17 +16,18 @@ export default function HomePage() {
-
+
학습 대상 10종목
- 삼성전자, SK하이닉스, 에코프로비엠, 한미반도체, 두산에너빌리티, 한화에어로스페이스, - HD현대중공업, NAVER, KT&G, 한국가스공사 + 삼성전자, SK하이닉스, 에코프로비엠, 한미반도체, 두산에너빌리티, + 한화에어로스페이스, HD현대중공업, NAVER, KT&G, 한국가스공사
-
+
매칭/재학습
- 평일 16:30 KST 에 예측과 실제 종가 매칭, 일요일 02:00 KST 에 LGBM 재학습. + 평일 16:30 KST 예측↔실제 매칭, 일요일 02:00 KST LGBM 재학습. + 앙상블은 Chronos(zero-shot) + LightGBM.
diff --git a/web/components/PeriodTabs.tsx b/web/components/PeriodTabs.tsx new file mode 100644 index 0000000..276a40d --- /dev/null +++ b/web/components/PeriodTabs.tsx @@ -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 ( +
+ {PERIODS.map((p) => { + const active = value === p.id; + return ( + + ); + })} +
+ ); +} diff --git a/web/components/PredictionPanel.tsx b/web/components/PredictionPanel.tsx index 19e2aac..7d94564 100644 --- a/web/components/PredictionPanel.tsx +++ b/web/components/PredictionPanel.tsx @@ -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(initial ?? null); const [loading, setLoading] = useState(false); const [err, setErr] = useState(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 ( -
-
+
+
예측 (Chronos + LightGBM 앙상블)
- 클릭한 종목은 자동 저장 후 다음 거래일 장 종료 시 실제 가격과 비교됩니다. + 결과는 차트에 옅은 색 캔들로 이어 붙고, 꼬리 길이가 신뢰구간 폭입니다.
- 예측 거래일: - {HORIZON_PRESETS.map((p, i) => ( + 예측 기간: + {PREDICT_PRESETS.map((p, i) => ( ))} - + {presetIdx === 2 && ( + + ※ 30거래일 너머는 모델 학습 범위 밖 — 참고용 + + )}
{err &&
에러: {err}
} @@ -156,54 +122,56 @@ export function PredictionPanel({ code, initial, onResult }: Props) { 기준일 {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() : "-"} -
+ + + + + + + + + - ))} - -
+거래일매칭일방향P(up/flat/down)기대수익예측 종가q10~q90
+ + + {steps.map((s) => ( + + +{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() : "-"} + + + ))} + + +
) : (
- 아직 예측이 없습니다. 예상차트 보기 버튼을 누르면 1·3·5거래일 후 예측을 생성하고 - 차트에 점선으로 이어 붙입니다. + 아직 예측이 없습니다. 예상차트 보기 를 누르면 선택한 기간의 예측 캔들이 + 현재 차트에 옅은 색으로 이어 붙습니다.
)}
diff --git a/web/components/PriceHero.tsx b/web/components/PriceHero.tsx new file mode 100644 index 0000000..c282227 --- /dev/null +++ b/web/components/PriceHero.tsx @@ -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 ( +
+
+ {code} · {market} +
+

+ {name} +

+
+ + {current != null ? current.toLocaleString() : "-"} + + {change != null && pct != null && ( + + {arrow} {Math.abs(change).toLocaleString()}{" "} + ({pct >= 0 ? "+" : ""} + {pct.toFixed(2)}%) + + )} +
+ {asOf && ( +
기준 {asOf}
+ )} +
+ ); +} diff --git a/web/components/StockChart.tsx b/web/components/StockChart.tsx index e57c456..ce077be 100644 --- a/web/components/StockChart.tsx +++ b/web/components/StockChart.tsx @@ -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(null); const chartRef = useRef(null); const candleRef = useRef | null>(null); - const predRef = useRef | null>(null); - const predLowRef = useRef | null>(null); - const predHighRef = useRef | null>(null); + const predRef = useRef | 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 ( -
+
- {todayLabel && ( -
- - 오늘 · {todayLabel} + {prediction?.found && !isIntraday && ( +
+ + + 예측 (꼬리 = q10~q90 신뢰구간)
)}