- backend/app/api/chart.py: interval=10m|1d|1w|1mo. 10m 은 ohlcv_1m 을 time_bucket(10min) 으로 집계, stale(>10분) 이면 KIS 분봉 fetch 후 재조회. 1w/1mo 는 ohlcv_daily 를 date_trunc 로 집계. today 필드 추가. - backend/app/fetch/kis.py: fetch_minute_price() 추가 (tr_id FHKST03010200). KIS 응답 KST 시각을 tz-aware datetime 으로 변환, 오름차순 정렬. - web/lib/api.ts: ChartInterval 타입, getChart(interval), predict(horizons[]). - web/components/StockChart.tsx: 10m 이면 timeVisible. 일·주·월에서 오늘 화살표 마커 표시. ISO datetime 도 파싱. - web/components/PredictionPanel.tsx: 단기/중기/장기 프리셋 + 사용자 직접 지정 (예: 1,2,3,7). API 에 horizons 배열 전달. - web/app/[code]/page.tsx: interval 칩 (10분/일/주/월). 10m 일 때 60초마다 폴링. interval 별 기본 lookback (10m=1, 1d=180, 1w=730, 1mo=1825).
159 lines
5.1 KiB
TypeScript
159 lines
5.1 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useEffect, useState } from "react";
|
|
import { MetricsPanel } from "../../components/MetricsPanel";
|
|
import { NewsList } from "../../components/NewsList";
|
|
import { PredictionPanel } from "../../components/PredictionPanel";
|
|
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 },
|
|
];
|
|
|
|
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);
|
|
|
|
// interval 바꾸면 days 도 그 interval 에 맞는 기본값으로 (사용자가 명시적으로 다시 고를 수 있게).
|
|
function pickInterval(v: ChartInterval) {
|
|
const meta = INTERVALS.find((i) => i.value === v)!;
|
|
setIntervalKind(v);
|
|
setDays(meta.defaultDays);
|
|
}
|
|
|
|
// 초기/주기적 차트 로드. 10분봉이면 60초마다 폴링 — 백엔드가 캐시-then-fetch 로
|
|
// 10분 이내면 DB 만 읽고, 넘었으면 KIS 호출. 폴링 부담은 낮음.
|
|
useEffect(() => {
|
|
let alive = true;
|
|
setErr(null);
|
|
setChart(null);
|
|
|
|
const load = () => {
|
|
api
|
|
.getChart(code, days, interval)
|
|
.then((c) => {
|
|
if (alive) setChart(c);
|
|
})
|
|
.catch((e) => {
|
|
if (alive) setErr(e instanceof Error ? e.message : String(e));
|
|
});
|
|
};
|
|
load();
|
|
|
|
if (interval === "10m") {
|
|
const h = window.setInterval(load, 60_000);
|
|
return () => {
|
|
alive = false;
|
|
window.clearInterval(h);
|
|
};
|
|
}
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, [code, days, interval]);
|
|
|
|
useEffect(() => {
|
|
let alive = true;
|
|
api
|
|
.latestPrediction(code)
|
|
.then((r) => {
|
|
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>
|
|
|
|
{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>
|
|
)}
|
|
|
|
{err && <div className="mb-4 text-sm text-red-400">차트 로딩 실패: {err}</div>}
|
|
|
|
{chart && (
|
|
<>
|
|
<StockChart chart={chart} prediction={prediction} />
|
|
<div className="mt-6">
|
|
<PredictionPanel code={code} initial={prediction} onResult={setPrediction} />
|
|
</div>
|
|
<div className="mt-6 grid gap-6 md:grid-cols-2">
|
|
<MetricsPanel code={code} />
|
|
<NewsList code={code} />
|
|
</div>
|
|
</>
|
|
)}
|
|
</main>
|
|
);
|
|
}
|