feat(phase-6): Next.js UI + TypeScript strict + 백엔드 mypy 설정
UI: - web/lib/api.ts: 백엔드 모든 엔드포인트의 클라이언트 + 타입 (Symbol, ChartPayload, PredictResponse, LatestPredictionResponse, MetricsResponse, NewsResponse). NEXT_PUBLIC_API_BASE 자동 정규화. - web/components/SearchBox: 디바운스 검색, seed_only 토글, trigram + prefix. - web/components/StockChart: lightweight-charts 캔들 + 예측 overlay (median dashed + q10/q90 점선). base_date 에서 target_date 까지 이어 붙임. - web/components/PredictionPanel: "예상차트 보기" 버튼 → POST /api/predict → user_triggered=TRUE 저장 → onResult 콜백으로 StockChart 에 반영. 표로 +1/+3/+5거래일 direction, prob_up/flat/down, expected_return, ci_low~ci_high 표시. - web/components/MetricsPanel: 최근 30일 hit_rate / mae. - web/components/NewsList: 최근 뉴스 + 감성 라벨/점수. - web/app/page.tsx: 검색 페이지. - web/app/[code]/page.tsx: 종목 상세 (차트 + 패널 + 메트릭 + 뉴스). TypeScript 보강 (사용자 요청 "typescript도 추가해서 나중에 수정하기 쉽게"): - tsconfig.json: strict 외에 forceConsistentCasingInFileNames, noFallthroughCasesInSwitch, noImplicitOverride 추가. - package.json: typecheck (tsc --noEmit), check (typecheck + lint) 스크립트, eslint + eslint-config-next 14.2.3. - .eslintrc.json: next/core-web-vitals. - package-lock.json 커밋 (재현 가능한 dep). 백엔드: - pyproject.toml: [tool.mypy] 추가. strict_optional, no_implicit_optional, check_untyped_defs. 3rd-party stub 없는 pykrx/chronos 등은 ignore. 검증: `npx tsc --noEmit` 통과 (exit=0). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -66,3 +66,19 @@ include = ["app*"]
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
strict_optional = true
|
||||
warn_unused_ignores = true
|
||||
warn_redundant_casts = true
|
||||
warn_unreachable = true
|
||||
ignore_missing_imports = true # 3rd-party stub 부족 무시 (pykrx, chronos, ta, feedparser ...)
|
||||
no_implicit_optional = true
|
||||
show_error_codes = true
|
||||
exclude = ["build", "dist", ".venv"]
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["app.*"]
|
||||
disallow_untyped_defs = false # 점진적 도입. 핵심 모듈만 strict 로 올릴 예정.
|
||||
check_untyped_defs = true
|
||||
|
||||
6
web/.eslintrc.json
Normal file
6
web/.eslintrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals",
|
||||
"rules": {
|
||||
"react-hooks/exhaustive-deps": "warn"
|
||||
}
|
||||
}
|
||||
99
web/app/[code]/page.tsx
Normal file
99
web/app/[code]/page.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"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 ChartPayload,
|
||||
type LatestPredictionResponse,
|
||||
} from "../../lib/api";
|
||||
|
||||
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 [days, setDays] = useState(180);
|
||||
|
||||
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));
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [code, days]);
|
||||
|
||||
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>
|
||||
<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>
|
||||
|
||||
{chart && (
|
||||
<div className="mb-4">
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,32 @@
|
||||
import { SearchBox } from "../components/SearchBox";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-6 py-16">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Stock Chart Site</h1>
|
||||
<p className="mt-3 text-sm text-zinc-400">
|
||||
Phase 0 scaffold. 종목 검색 UI는 Phase 6에서 추가됩니다.
|
||||
<p className="mt-2 text-sm text-zinc-400">
|
||||
종목을 검색해 현재 차트를 보고, <b>예상차트 보기</b> 버튼으로 Chronos + LightGBM
|
||||
앙상블의 단기(1·3·5거래일) 예측을 차트에 이어 붙입니다.
|
||||
</p>
|
||||
<div className="mt-8 rounded-md border border-zinc-800 bg-zinc-900/50 p-4 text-sm">
|
||||
<div className="font-medium">Backend health</div>
|
||||
<code className="mt-2 block text-zinc-400">GET {process.env.NEXT_PUBLIC_API_BASE}/health</code>
|
||||
|
||||
<div className="mt-8">
|
||||
<SearchBox autoFocus />
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid gap-3 text-xs text-zinc-500 sm:grid-cols-2">
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/30 p-4">
|
||||
<div className="font-medium text-zinc-300">학습 대상 10종목</div>
|
||||
<div className="mt-1">
|
||||
삼성전자, SK하이닉스, 에코프로비엠, 한미반도체, 두산에너빌리티, 한화에어로스페이스,
|
||||
HD현대중공업, NAVER, KT&G, 한국가스공사
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/30 p-4">
|
||||
<div className="font-medium text-zinc-300">매칭/재학습</div>
|
||||
<div className="mt-1">
|
||||
평일 16:30 KST 에 예측과 실제 종가 매칭, 일요일 02:00 KST 에 LGBM 재학습.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
64
web/components/MetricsPanel.tsx
Normal file
64
web/components/MetricsPanel.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type MetricsResponse } from "../lib/api";
|
||||
|
||||
export function MetricsPanel({ code }: { code: string }) {
|
||||
const [m, setM] = useState<MetricsResponse | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api
|
||||
.metrics(code, 30)
|
||||
.then((r) => {
|
||||
if (alive) setM(r);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) setErr(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [code]);
|
||||
|
||||
if (err) return <div className="text-xs text-red-400">메트릭 로딩 실패: {err}</div>;
|
||||
if (!m) return <div className="text-xs text-zinc-500">메트릭 로딩 중…</div>;
|
||||
|
||||
const rows = m.by_model_horizon ?? [];
|
||||
if (!rows.length) {
|
||||
return (
|
||||
<div className="text-xs text-zinc-500">
|
||||
최근 30일 매칭된 예측 결과가 아직 없습니다. (매칭 배치는 평일 16:30 KST 에 실행)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||
<div className="mb-2 text-sm font-medium text-zinc-200">최근 30일 모델 성능</div>
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs text-zinc-500">
|
||||
<tr>
|
||||
<th className="py-1">모델</th>
|
||||
<th>+거래일</th>
|
||||
<th>표본 수</th>
|
||||
<th>방향 적중률</th>
|
||||
<th>평균 절대오차 (MAE)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td className="py-1">{r.model}</td>
|
||||
<td>+{r.horizon}</td>
|
||||
<td>{r.n}</td>
|
||||
<td>{r.hit_rate != null ? `${(r.hit_rate * 100).toFixed(1)}%` : "-"}</td>
|
||||
<td>{r.mae != null ? r.mae.toLocaleString(undefined, { maximumFractionDigits: 1 }) : "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
web/components/NewsList.tsx
Normal file
77
web/components/NewsList.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type NewsResponse } from "../lib/api";
|
||||
|
||||
export function NewsList({ code }: { code: string }) {
|
||||
const [data, setData] = useState<NewsResponse | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api
|
||||
.news(code, 20)
|
||||
.then((r) => {
|
||||
if (alive) setData(r);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) setErr(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [code]);
|
||||
|
||||
if (err) return <div className="text-xs text-red-400">뉴스 로딩 실패: {err}</div>;
|
||||
if (!data) return <div className="text-xs text-zinc-500">뉴스 로딩 중…</div>;
|
||||
if (!data.items.length)
|
||||
return <div className="text-xs text-zinc-500">최근 뉴스 없음</div>;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||
<div className="mb-2 text-sm font-medium text-zinc-200">최근 뉴스/공시</div>
|
||||
<ul className="divide-y divide-zinc-800">
|
||||
{data.items.map((n, i) => (
|
||||
<li key={i} className="py-2">
|
||||
<a
|
||||
href={n.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block hover:bg-zinc-800/40"
|
||||
>
|
||||
<div className="text-sm text-zinc-100 line-clamp-2">{n.title}</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-xs text-zinc-500">
|
||||
<span>{n.source}</span>
|
||||
{n.published_at && <span>· {formatDate(n.published_at)}</span>}
|
||||
{n.sentiment_label && (
|
||||
<span className={sentimentColor(n.sentiment_label)}>
|
||||
· {n.sentiment_label} {n.sentiment_score != null ? `(${n.sentiment_score.toFixed(2)})` : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sentimentColor(l: string): string {
|
||||
if (l === "positive") return "text-emerald-400";
|
||||
if (l === "negative") return "text-red-400";
|
||||
return "text-zinc-400";
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function pad(n: number): string {
|
||||
return n < 10 ? `0${n}` : `${n}`;
|
||||
}
|
||||
157
web/components/PredictionPanel.tsx
Normal file
157
web/components/PredictionPanel.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
api,
|
||||
type LatestPredictionResponse,
|
||||
type LatestPredictionStep,
|
||||
type PredictResponse,
|
||||
} from "../lib/api";
|
||||
|
||||
type Props = {
|
||||
code: string;
|
||||
initial?: LatestPredictionResponse | null;
|
||||
onResult: (pred: LatestPredictionResponse) => void;
|
||||
};
|
||||
|
||||
function normalizeFromPredictResponse(
|
||||
code: string,
|
||||
resp: PredictResponse,
|
||||
): LatestPredictionResponse {
|
||||
const steps: LatestPredictionStep[] = resp.steps.map((s) => ({
|
||||
predicted_at: null,
|
||||
target_date: s.target_date ?? "",
|
||||
horizon: s.horizon,
|
||||
direction: s.direction,
|
||||
prob_up: s.prob_up,
|
||||
prob_flat: s.prob_flat,
|
||||
prob_down: s.prob_down,
|
||||
expected_return: s.expected_return,
|
||||
point_close: s.point_close,
|
||||
ci_low: s.ci_low,
|
||||
ci_high: s.ci_high,
|
||||
user_triggered: resp.user_triggered,
|
||||
features_snapshot: null,
|
||||
}));
|
||||
return {
|
||||
code,
|
||||
found: true,
|
||||
base_date: resp.base_date,
|
||||
base_close: resp.base_close,
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
async function runPredict() {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await api.predict(code);
|
||||
const normalized = normalizeFromPredictResponse(code, r);
|
||||
setPred(normalized);
|
||||
onResult(normalized);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const steps = pred?.steps ?? [];
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-zinc-200">예측 (Chronos + LightGBM 앙상블)</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
클릭한 종목은 자동 저장 후 다음 거래일 장 종료 시 실제 가격과 비교됩니다.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={runPredict}
|
||||
disabled={loading}
|
||||
className="rounded-md bg-emerald-700 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "예측 중…" : pred?.found ? "다시 예측" : "예상차트 보기"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{err && <div className="mb-3 text-xs text-red-400">에러: {err}</div>}
|
||||
|
||||
{pred?.found ? (
|
||||
<div>
|
||||
<div className="mb-2 text-xs text-zinc-500">
|
||||
기준일 {pred.base_date} · 기준종가{" "}
|
||||
{pred.base_close != null ? pred.base_close.toLocaleString() : "-"}
|
||||
</div>
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs text-zinc-500">
|
||||
<tr>
|
||||
<th className="py-1">+거래일</th>
|
||||
<th>매칭일</th>
|
||||
<th>방향</th>
|
||||
<th>P(up/flat/down)</th>
|
||||
<th>기대수익</th>
|
||||
<th>예측 종가</th>
|
||||
<th>q10~q90</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{steps.map((s) => (
|
||||
<tr key={s.horizon}>
|
||||
<td className="py-2">+{s.horizon}</td>
|
||||
<td className="text-xs text-zinc-400">{s.target_date}</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
s.direction === "up"
|
||||
? "text-emerald-400"
|
||||
: s.direction === "down"
|
||||
? "text-red-400"
|
||||
: "text-zinc-300"
|
||||
}
|
||||
>
|
||||
{s.direction}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-xs text-zinc-300">
|
||||
{fmtPct(s.prob_up)} / {fmtPct(s.prob_flat)} / {fmtPct(s.prob_down)}
|
||||
</td>
|
||||
<td>{fmtSignedPct(s.expected_return)}</td>
|
||||
<td>{s.point_close != null ? s.point_close.toLocaleString() : "-"}</td>
|
||||
<td className="text-xs text-zinc-400">
|
||||
{s.ci_low != null ? s.ci_low.toLocaleString() : "-"} ~{" "}
|
||||
{s.ci_high != null ? s.ci_high.toLocaleString() : "-"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-zinc-500">
|
||||
아직 예측이 없습니다. <b>예상차트 보기</b> 버튼을 누르면 1·3·5거래일 후 예측을 생성하고
|
||||
차트에 점선으로 이어 붙입니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtPct(v: number | null): string {
|
||||
if (v == null) return "-";
|
||||
return `${(v * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
function fmtSignedPct(v: number | null): string {
|
||||
if (v == null) return "-";
|
||||
const pct = v * 100;
|
||||
const sign = pct >= 0 ? "+" : "";
|
||||
return `${sign}${pct.toFixed(2)}%`;
|
||||
}
|
||||
93
web/components/SearchBox.tsx
Normal file
93
web/components/SearchBox.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type Symbol } from "../lib/api";
|
||||
|
||||
export function SearchBox({ autoFocus = false }: { autoFocus?: boolean }) {
|
||||
const [q, setQ] = useState("");
|
||||
const [items, setItems] = useState<Symbol[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [seedOnly, setSeedOnly] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const term = q.trim();
|
||||
if (!term) {
|
||||
setItems([]);
|
||||
setErr(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
const handle = setTimeout(async () => {
|
||||
try {
|
||||
const r = await api.search(term, seedOnly, 15);
|
||||
setItems(r.items);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
setItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 200);
|
||||
return () => clearTimeout(handle);
|
||||
}, [q, seedOnly]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
autoFocus={autoFocus}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="종목명 또는 코드 (예: 삼성, 005930)"
|
||||
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-zinc-500"
|
||||
/>
|
||||
<label className="flex shrink-0 items-center gap-2 text-xs text-zinc-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={seedOnly}
|
||||
onChange={(e) => setSeedOnly(e.target.checked)}
|
||||
/>
|
||||
학습대상 10종목만
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 min-h-[1.5rem] text-xs text-zinc-500">
|
||||
{loading && "검색중…"}
|
||||
{err && <span className="text-red-400">에러: {err}</span>}
|
||||
{!loading && !err && q && items.length === 0 && "검색 결과 없음"}
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<ul className="mt-2 divide-y divide-zinc-800 rounded-md border border-zinc-800 bg-zinc-900/40">
|
||||
{items.map((it) => (
|
||||
<li key={it.code}>
|
||||
<Link
|
||||
href={`/${it.code}`}
|
||||
className="flex items-center justify-between px-4 py-3 text-sm hover:bg-zinc-800/70"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-zinc-100">
|
||||
{it.name}
|
||||
{it.is_seed && (
|
||||
<span className="ml-2 rounded-full bg-emerald-900/60 px-2 py-0.5 text-[10px] font-semibold text-emerald-300">
|
||||
SEED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{it.code} · {it.market}
|
||||
{it.sector ? ` · ${it.sector}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-zinc-500">→</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
167
web/components/StockChart.tsx
Normal file
167
web/components/StockChart.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
createChart,
|
||||
type CandlestickData,
|
||||
type IChartApi,
|
||||
type ISeriesApi,
|
||||
type LineData,
|
||||
type UTCTimestamp,
|
||||
} from "lightweight-charts";
|
||||
import type { ChartPayload, LatestPredictionResponse } from "../lib/api";
|
||||
|
||||
type Props = {
|
||||
chart: ChartPayload;
|
||||
prediction?: LatestPredictionResponse | null;
|
||||
};
|
||||
|
||||
function dateToUtcTs(d: string): UTCTimestamp {
|
||||
// 'YYYY-MM-DD' → UTC midnight epoch seconds
|
||||
return (Date.UTC(
|
||||
Number(d.slice(0, 4)),
|
||||
Number(d.slice(5, 7)) - 1,
|
||||
Number(d.slice(8, 10)),
|
||||
) / 1000) as UTCTimestamp;
|
||||
}
|
||||
|
||||
export function StockChart({ chart, prediction }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
const candleRef = useRef<ISeriesApi<"Candlestick"> | null>(null);
|
||||
const predRef = useRef<ISeriesApi<"Line"> | null>(null);
|
||||
const predLowRef = useRef<ISeriesApi<"Line"> | null>(null);
|
||||
const predHighRef = useRef<ISeriesApi<"Line"> | null>(null);
|
||||
|
||||
// create chart once
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const c = createChart(containerRef.current, {
|
||||
layout: {
|
||||
background: { color: "transparent" },
|
||||
textColor: "#cbd5e1",
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: "#1f2937" },
|
||||
horzLines: { color: "#1f2937" },
|
||||
},
|
||||
rightPriceScale: { borderColor: "#374151" },
|
||||
timeScale: { borderColor: "#374151", timeVisible: false },
|
||||
autoSize: true,
|
||||
});
|
||||
const candle = c.addCandlestickSeries({
|
||||
upColor: "#22c55e",
|
||||
downColor: "#ef4444",
|
||||
borderUpColor: "#22c55e",
|
||||
borderDownColor: "#ef4444",
|
||||
wickUpColor: "#22c55e",
|
||||
wickDownColor: "#ef4444",
|
||||
});
|
||||
chartRef.current = c;
|
||||
candleRef.current = candle;
|
||||
return () => {
|
||||
c.remove();
|
||||
chartRef.current = null;
|
||||
candleRef.current = null;
|
||||
predRef.current = null;
|
||||
predLowRef.current = null;
|
||||
predHighRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// push candle data
|
||||
useEffect(() => {
|
||||
if (!candleRef.current) return;
|
||||
const data: CandlestickData[] = chart.ohlcv
|
||||
.filter((p) => p.open !== null && p.high !== null && p.low !== null && p.close !== null)
|
||||
.map((p) => ({
|
||||
time: dateToUtcTs(p.date),
|
||||
open: p.open as number,
|
||||
high: p.high as number,
|
||||
low: p.low as number,
|
||||
close: p.close as number,
|
||||
}));
|
||||
candleRef.current.setData(data);
|
||||
chartRef.current?.timeScale().fitContent();
|
||||
}, [chart]);
|
||||
|
||||
// push prediction overlay
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return;
|
||||
// remove previous overlay
|
||||
if (predRef.current) {
|
||||
chartRef.current.removeSeries(predRef.current);
|
||||
predRef.current = null;
|
||||
}
|
||||
if (predLowRef.current) {
|
||||
chartRef.current.removeSeries(predLowRef.current);
|
||||
predLowRef.current = null;
|
||||
}
|
||||
if (predHighRef.current) {
|
||||
chartRef.current.removeSeries(predHighRef.current);
|
||||
predHighRef.current = null;
|
||||
}
|
||||
if (!prediction || !prediction.found || !prediction.steps?.length) return;
|
||||
const baseDate = prediction.base_date!;
|
||||
const baseClose = prediction.base_close;
|
||||
if (!baseClose) return;
|
||||
const sorted = [...prediction.steps].sort((a, b) => a.horizon - b.horizon);
|
||||
|
||||
const med: LineData[] = [
|
||||
{ time: dateToUtcTs(baseDate), value: baseClose },
|
||||
...sorted
|
||||
.filter((s) => s.point_close !== null)
|
||||
.map((s) => ({ time: dateToUtcTs(s.target_date), value: s.point_close as number })),
|
||||
];
|
||||
const lo: LineData[] = [
|
||||
{ time: dateToUtcTs(baseDate), value: baseClose },
|
||||
...sorted
|
||||
.filter((s) => s.ci_low !== null)
|
||||
.map((s) => ({ time: dateToUtcTs(s.target_date), value: s.ci_low as number })),
|
||||
];
|
||||
const hi: LineData[] = [
|
||||
{ time: dateToUtcTs(baseDate), value: baseClose },
|
||||
...sorted
|
||||
.filter((s) => s.ci_high !== null)
|
||||
.map((s) => ({ time: dateToUtcTs(s.target_date), value: s.ci_high as number })),
|
||||
];
|
||||
|
||||
const medLine = chartRef.current.addLineSeries({
|
||||
color: "#a78bfa",
|
||||
lineWidth: 2,
|
||||
lineStyle: 2, // dashed
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: true,
|
||||
title: "예측 median",
|
||||
});
|
||||
medLine.setData(med);
|
||||
const loLine = chartRef.current.addLineSeries({
|
||||
color: "#7c3aed",
|
||||
lineWidth: 1,
|
||||
lineStyle: 1,
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: false,
|
||||
title: "q10",
|
||||
});
|
||||
loLine.setData(lo);
|
||||
const hiLine = chartRef.current.addLineSeries({
|
||||
color: "#7c3aed",
|
||||
lineWidth: 1,
|
||||
lineStyle: 1,
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: false,
|
||||
title: "q90",
|
||||
});
|
||||
hiLine.setData(hi);
|
||||
predRef.current = medLine;
|
||||
predLowRef.current = loLine;
|
||||
predHighRef.current = hiLine;
|
||||
chartRef.current.timeScale().fitContent();
|
||||
}, [prediction]);
|
||||
|
||||
return (
|
||||
<div className="h-[460px] w-full rounded-md border border-zinc-800 bg-zinc-900/30 p-2">
|
||||
<div ref={containerRef} className="h-full w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
web/lib/api.ts
Normal file
178
web/lib/api.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
// Backend API client.
|
||||
// NEXT_PUBLIC_API_BASE 는 docker-compose 에서 http://localhost:8000 으로 주입됨.
|
||||
|
||||
const RAW_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
|
||||
export const API_BASE = RAW_BASE.replace(/\/$/, "");
|
||||
|
||||
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 = {
|
||||
date: string;
|
||||
open: number | null;
|
||||
high: number | null;
|
||||
low: number | null;
|
||||
close: number | null;
|
||||
volume: number | null;
|
||||
};
|
||||
|
||||
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;
|
||||
range: { from: string; to: 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[];
|
||||
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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
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<SymbolSearch>(
|
||||
`/api/symbols/search?q=${encodeURIComponent(q)}&limit=${limit}&seed_only=${seedOnly}`,
|
||||
),
|
||||
getSymbol: (code: string) => getJson<Symbol>(`/api/symbols/${encodeURIComponent(code)}`),
|
||||
getChart: (code: string, days = 180) =>
|
||||
getJson<ChartPayload>(`/api/chart/${encodeURIComponent(code)}?days=${days}`),
|
||||
predict: (code: string, horizons = "1,3,5") =>
|
||||
getJson<PredictResponse>(
|
||||
`/api/predict/${encodeURIComponent(code)}?horizons=${encodeURIComponent(horizons)}`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
latestPrediction: (code: string) =>
|
||||
getJson<LatestPredictionResponse>(`/api/predict/${encodeURIComponent(code)}/latest`),
|
||||
metrics: (code: string, windowDays = 30) =>
|
||||
getJson<MetricsResponse>(
|
||||
`/api/metrics/${encodeURIComponent(code)}?window_days=${windowDays}`,
|
||||
),
|
||||
overallMetrics: (windowDays = 30) =>
|
||||
getJson<MetricsResponse>(`/api/metrics?window_days=${windowDays}`),
|
||||
news: (code: string, limit = 20, source?: string) =>
|
||||
getJson<NewsResponse>(
|
||||
`/api/news/${encodeURIComponent(code)}?limit=${limit}${
|
||||
source ? `&source=${encodeURIComponent(source)}` : ""
|
||||
}`,
|
||||
),
|
||||
};
|
||||
5772
web/package-lock.json
generated
Normal file
5772
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,9 @@
|
||||
"dev": "next dev -p 3000 -H 0.0.0.0",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000 -H 0.0.0.0",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check": "npm run typecheck && npm run lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.3",
|
||||
@@ -21,6 +23,8 @@
|
||||
"typescript": "5.4.5",
|
||||
"tailwindcss": "3.4.4",
|
||||
"postcss": "8.4.38",
|
||||
"autoprefixer": "10.4.19"
|
||||
"autoprefixer": "10.4.19",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-next": "14.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitOverride": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./*"] }
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user