feat(chart): 10m 실시간 / 일·주·월 토글 / 오늘 마커 / 예측 거래일 선택
- 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).
This commit is contained in:
@@ -8,33 +8,63 @@ 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);
|
||||
api
|
||||
.getChart(code, days)
|
||||
.then((c) => {
|
||||
if (alive) setChart(c);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) setErr(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
|
||||
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]);
|
||||
}, [code, days, interval]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -57,26 +87,55 @@ export default function CodePage({ params }: { params: { code: string } }) {
|
||||
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
|
||||
← 검색으로
|
||||
</Link>
|
||||
<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={1095}>최근 3년</option>
|
||||
</select>
|
||||
<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">
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user