// 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; // with_sparkline=true 일 때만 채워짐. sparkline?: number[]; close?: number | null; pct_change?: number | null; }; 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[]; }; export type Mover = { code: string; name: string; market: string; close: number | null; prev_close: number | null; volume: number | null; pct_change: number | null; trading_value?: number | null; }; export type MoversResponse = { market: string; limit: number; gainers: Mover[]; losers: Mover[]; by_volume: Mover[]; by_trading_value: Mover[]; }; export type RelatedResponse = { code: string; market: string; items: Mover[]; }; export type ThemeIndex = { slug: string; name: string; description: string; code_count: number; }; export type ThemesIndexResponse = { items: ThemeIndex[]; total: number; }; export type ThemeDetailResponse = { slug: string; name: string; description: string; items: Mover[]; }; export type PeerCompareItem = { code: string; name: string; market: string | null; close: number | null; base_close: number | null; pct_change: number | null; }; export type PeerCompareResponse = { code: string; name?: string; window: number; themes: { slug: string; name: string; code_count: number }[]; self_pct: number | null; peer_avg_pct: number | null; peer_count?: number; top: PeerCompareItem[]; bottom: PeerCompareItem[]; }; export type IndexPoint = { date: string; value: number }; export type IndexSeries = { key: "kospi" | "kosdaq"; name: string; points: IndexPoint[]; latest: number | null; prev: number | null; pct_change: number | null; }; export type IndicesResponse = { days: number; indices: IndexSeries[]; }; export type OrderbookLevel = { level: number; price: number; qty: number; }; export type FundamentalsResponse = { code: string; name: string; market: string; status: "ok" | "no_data"; snapshot_date: string | null; market_cap: number | null; shares_outstanding: number | null; per: number | null; pbr: number | null; eps: number | null; bps: number | null; div: number | null; // 주당배당금 (원) dvd_yield: number | null; // 배당수익률 (%) }; export type OrderbookResponse = { code: string; name: string; market: string; status: "ok" | "skipped_missing_key"; ts: string | null; current: number | null; prev_close_diff: number | null; prev_close_pct: number | null; asks: OrderbookLevel[]; bids: OrderbookLevel[]; ask_total: number; bid_total: number; }; 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, withSparkline = false) => getJson( `/api/symbols/search?q=${encodeURIComponent(q)}&limit=${limit}&seed_only=${seedOnly}&with_sparkline=${withSparkline}`, ), 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)}` : "" }`, ), movers: (market: "ALL" | "KOSPI" | "KOSDAQ" = "ALL", limit = 10) => getJson(`/api/markets/movers?market=${market}&limit=${limit}`), related: (code: string, limit = 8) => getJson( `/api/markets/related/${encodeURIComponent(code)}?limit=${limit}`, ), indices: (days = 60) => getJson(`/api/markets/indices?days=${days}`), themesIndex: () => getJson(`/api/themes`), themeDetail: (slug: string, limit = 30) => getJson( `/api/themes/${encodeURIComponent(slug)}?limit=${limit}`, ), peerCompare: (code: string, window = 21, limit = 5) => getJson( `/api/themes/peer/${encodeURIComponent(code)}?window=${window}&limit=${limit}`, ), orderbook: (code: string) => getJson(`/api/orderbook/${encodeURIComponent(code)}`), fundamentals: (code: string) => getJson(`/api/fundamentals/${encodeURIComponent(code)}`), recordView: (code: string) => getJson(`/api/views/${encodeURIComponent(code)}`, { method: "POST", }), views: (code: string) => getJson(`/api/views/${encodeURIComponent(code)}`), }; export type RecordViewResponse = { ok: boolean; code?: string; today_views: number; }; export type ViewsTrendPoint = { date: string; views: number; }; export type ViewsResponse = { code: string; today_views: number; last_7d_views: number; trend: ViewsTrendPoint[]; };