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:
@@ -42,16 +42,37 @@ 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] },
|
||||
];
|
||||
|
||||
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) {
|
||||
const parsed = customRaw
|
||||
.split(",")
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => Number.isFinite(n) && n >= 1 && n <= 60);
|
||||
if (parsed.length > 0) return Array.from(new Set(parsed)).sort((a, b) => a - b);
|
||||
}
|
||||
return HORIZON_PRESETS[presetIdx].value;
|
||||
}
|
||||
|
||||
async function runPredict() {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await api.predict(code);
|
||||
const horizons = effectiveHorizons();
|
||||
const r = await api.predict(code, horizons);
|
||||
const normalized = normalizeFromPredictResponse(code, r);
|
||||
setPred(normalized);
|
||||
onResult(normalized);
|
||||
@@ -82,6 +103,49 @@ export function PredictionPanel({ code, initial, onResult }: Props) {
|
||||
</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) => (
|
||||
<button
|
||||
key={p.label}
|
||||
onClick={() => {
|
||||
setUseCustom(false);
|
||||
setPresetIdx(i);
|
||||
}}
|
||||
className={
|
||||
!useCustom && presetIdx === i
|
||||
? "rounded-full bg-emerald-700 px-3 py-1 text-white"
|
||||
: "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,2,3,7"
|
||||
value={customRaw}
|
||||
onChange={(e) => setCustomRaw(e.target.value)}
|
||||
onFocus={() => setUseCustom(true)}
|
||||
className="ml-1 w-24 rounded border border-zinc-700 bg-zinc-900 px-1 py-0.5 text-xs text-zinc-100"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{err && <div className="mb-3 text-xs text-red-400">에러: {err}</div>}
|
||||
|
||||
{pred?.found ? (
|
||||
|
||||
Reference in New Issue
Block a user