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:
tkrmagid
2026-05-20 16:10:24 +09:00
parent 41ee9d5bb0
commit 4fb6cec383
13 changed files with 6662 additions and 7 deletions

View File

@@ -66,3 +66,19 @@ include = ["app*"]
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
target-version = "py311" 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
View File

@@ -0,0 +1,6 @@
{
"extends": "next/core-web-vitals",
"rules": {
"react-hooks/exhaustive-deps": "warn"
}
}

99
web/app/[code]/page.tsx Normal file
View 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>
);
}

View File

@@ -1,13 +1,32 @@
import { SearchBox } from "../components/SearchBox";
export default function HomePage() { export default function HomePage() {
return ( return (
<main className="mx-auto max-w-3xl px-6 py-16"> <main className="mx-auto max-w-3xl px-6 py-16">
<h1 className="text-3xl font-bold tracking-tight">Stock Chart Site</h1> <h1 className="text-3xl font-bold tracking-tight">Stock Chart Site</h1>
<p className="mt-3 text-sm text-zinc-400"> <p className="mt-2 text-sm text-zinc-400">
Phase 0 scaffold. UI는 Phase 6 . , <b> </b> Chronos + LightGBM
(1·3·5) .
</p> </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> <div className="mt-8">
<code className="mt-2 block text-zinc-400">GET {process.env.NEXT_PUBLIC_API_BASE}/health</code> <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&amp;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> </div>
</main> </main>
); );

View 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>
);
}

View 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}`;
}

View 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)}%`;
}

View 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>
);
}

View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,9 @@
"dev": "next dev -p 3000 -H 0.0.0.0", "dev": "next dev -p 3000 -H 0.0.0.0",
"build": "next build", "build": "next build",
"start": "next start -p 3000 -H 0.0.0.0", "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": { "dependencies": {
"next": "14.2.3", "next": "14.2.3",
@@ -21,6 +23,8 @@
"typescript": "5.4.5", "typescript": "5.4.5",
"tailwindcss": "3.4.4", "tailwindcss": "3.4.4",
"postcss": "8.4.38", "postcss": "8.4.38",
"autoprefixer": "10.4.19" "autoprefixer": "10.4.19",
"eslint": "8.57.0",
"eslint-config-next": "14.2.3"
} }
} }

View File

@@ -13,6 +13,9 @@
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "preserve",
"incremental": true, "incremental": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"plugins": [{ "name": "next" }], "plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] } "paths": { "@/*": ["./*"] }
}, },