// Backend API client. // // API 베이스 해석 우선순위: // 1) NEXT_PUBLIC_API_BASE 가 localhost/127.0.0.1 이 아닌 명시값 → 그대로 사용 // (예: 프로덕션 https://api.example.com) // 2) 브라우저 환경 → window.location.hostname:8000 (LAN 접속도 자동 대응) // 3) SSR 폴백 → http://localhost:8000 // // docker-compose 가 NEXT_PUBLIC_API_BASE=http://localhost:8000 을 주입하는 경우가 흔한데, // LAN 의 다른 PC 에서 http://:3000 으로 접속하면 inline 된 localhost 가 그쪽 PC 의 // localhost 를 가리켜 깨진다. 그래서 localhost/127.0.0.1 값은 신뢰하지 않고 페이지 host 로 // 폴백. function resolveApiBase(): string { const raw = process.env.NEXT_PUBLIC_API_BASE; const env = raw && raw.length > 0 ? raw.replace(/\/$/, "") : ""; const envIsLocal = !env || /\/\/(localhost|127\.0\.0\.1)(?::|$)/.test(env); if (typeof window !== "undefined") { if (envIsLocal) { return `${window.location.protocol}//${window.location.hostname}:8000`; } return env; } // SSR return env || "http://localhost:8000"; } export const API_BASE = resolveApiBase(); export type Symbol = { code: string; name: string; market: string; sector: string | null; is_seed: boolean; }; export type SymbolSearch = { q: string; count: number; items: Symbol[]; }; export type OhlcvPoint = { // 1d/1w/1mo: 'YYYY-MM-DD' / 10m: 'YYYY-MM-DDTHH:MM:SS' (KST naive ISO) date: string; open: number | null; high: number | null; low: number | null; close: number | null; volume: number | null; }; export type ChartInterval = "10m" | "1d" | "1w" | "1mo"; export type SentimentPoint = { date: string; n_articles: number; mean_score: number | null; weighted_score: number | null; }; export type TradingValuePoint = { date: string; foreign_net: number | null; institution_net: number | null; individual_net: number | null; }; export type ChartPayload = { code: string; name: string; market: string; interval: ChartInterval; intraday_status: string | null; range: { from: string; to: string }; today: string; ohlcv: OhlcvPoint[]; sentiment: SentimentPoint[]; trading_value: TradingValuePoint[]; }; export type PredictionStep = { horizon: number; target_idx?: number; point_close: number; ci_low: number; ci_high: number; prob_up: number; prob_flat: number; prob_down: number; direction: "up" | "flat" | "down"; expected_return: number; target_date?: string; }; export type PredictResponse = { code: string; base_date: string; base_close: number; sources_used: string[]; steps: PredictionStep[]; saved_prediction_ids: number[]; saved_shadow_ids?: { chronos: number[]; lgbm: number[] }; user_triggered: boolean; }; export type LatestPredictionStep = { predicted_at: string | null; target_date: string; horizon: number; direction: "up" | "flat" | "down" | string; prob_up: number | null; prob_flat: number | null; prob_down: number | null; expected_return: number | null; point_close: number | null; ci_low: number | null; ci_high: number | null; user_triggered: boolean; features_snapshot: unknown; }; export type LatestPredictionResponse = { code: string; name?: string; found: boolean; base_date?: string; base_close?: number | null; steps: LatestPredictionStep[]; }; export type MetricsRow = { model: string; horizon: number; n: number; hit_rate: number | null; mae: number | null; }; export type MetricsResponse = { code?: string; name?: string; window_days: number; range: { from: string; to: string }; by_model_horizon: MetricsRow[]; }; export type NewsItem = { source: string; published_at: string | null; title: string; url: string; sentiment_score: number | null; sentiment_label: string | null; }; export type NewsResponse = { code: string; name: string; count: number; items: NewsItem[]; }; async function getJson(path: string, init?: RequestInit): Promise { const res = await fetch(`${API_BASE}${path}`, { ...init, headers: { Accept: "application/json", ...(init?.headers ?? {}), }, cache: "no-store", }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`API ${path} → ${res.status} ${text || res.statusText}`); } return (await res.json()) as T; } export const api = { search: (q: string, seedOnly = false, limit = 20) => getJson( `/api/symbols/search?q=${encodeURIComponent(q)}&limit=${limit}&seed_only=${seedOnly}`, ), getSymbol: (code: string) => getJson(`/api/symbols/${encodeURIComponent(code)}`), getChart: (code: string, days = 180, interval: ChartInterval = "1d") => getJson( `/api/chart/${encodeURIComponent(code)}?days=${days}&interval=${encodeURIComponent(interval)}`, ), predict: (code: string, horizons: string | number[] = "1,3,5") => { const h = Array.isArray(horizons) ? horizons.join(",") : horizons; return getJson( `/api/predict/${encodeURIComponent(code)}?horizons=${encodeURIComponent(h)}`, { method: "POST" }, ); }, latestPrediction: (code: string) => getJson(`/api/predict/${encodeURIComponent(code)}/latest`), metrics: (code: string, windowDays = 30) => getJson( `/api/metrics/${encodeURIComponent(code)}?window_days=${windowDays}`, ), overallMetrics: (windowDays = 30) => getJson(`/api/metrics?window_days=${windowDays}`), news: (code: string, limit = 20, source?: string) => getJson( `/api/news/${encodeURIComponent(code)}?limit=${limit}${ source ? `&source=${encodeURIComponent(source)}` : "" }`, ), };