mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-25 08:18:05 +00:00
feat: major codebase audit — 21 routers, 37 services, 12 pages fully documented
- Add missing numpy, scipy, dbnomics to requirements.txt (fixes ImportError on fresh install) - Sync claude.md with actual codebase: §3 file structure (37 services, 21 routers), §5 API endpoints (92 routes), §6 frontend pages (12), §13 TODO status - Update README.md with current architecture (92 API routes, 21 routers, 37 services), multi-asset overview, research grid, macro dashboard, screener+backtest, multi-jurisdiction filings, and 2026-03-26 changelog entry - Add new routers: dart, edinet, fmp, macro, research - Add new services: cache, dart_fetcher, dart_filing_service, economic_calendar, ecos_fetcher, edinet_filing_service, fmp_client, global_macro_quadrant, kpi_history_service, macro_cycle, macro_fetcher, oecd_cycle, peer_comparison_service, research_dashboard, smart_money_service, yield_fx_service - Add new frontend: macro page, screener+backtest, research grid components, overview (Equity/ETF/Commodity), filings (SEC/DART/EDINET), error boundaries - Remove 6 unused services: copilot_context, crypto_fetcher, fx_fetcher, gemini_analysis, market_data, technical_analysis - Remove obsolete docs: .agent/, AGENT.md, ATLAS_EVALUATION.md, docs/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
38c56a5a43
commit
51cbaf7f8d
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { TickerBar } from "./ticker-bar";
|
||||
import { ChatPanel } from "./chat-panel";
|
||||
|
||||
/**
|
||||
* Sidebar는 usePathname()을 씁니다. Next App Router에서 SSR 출력과 클라이언트 첫 페인트가
|
||||
* 미묘하게 어긋나면 hydration 실패 → 전체 트리가 비거나(흰 화면) 콘솔에 recoverable 에러가 납니다.
|
||||
* 서버에서는 사이드바를 그리지 않고(ssr: false) 클라이언트에서만 마운트해 그 클래스의 버그를 제거합니다.
|
||||
*/
|
||||
const SidebarClient = dynamic(
|
||||
() => import("./sidebar").then((m) => ({ default: m.Sidebar })),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<aside
|
||||
className="w-[260px] fixed top-[52px] bottom-0 left-0 z-40 border-r border-border bg-bg-primary"
|
||||
aria-hidden
|
||||
/>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<TickerBar />
|
||||
<div className="flex min-h-screen pt-[52px]">
|
||||
<SidebarClient />
|
||||
<main className="flex-1 ml-[260px] mr-[380px] p-7 bg-bg-primary min-h-[calc(100vh-52px)] transition-all duration-200">
|
||||
{children}
|
||||
</main>
|
||||
<ChatPanel />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
export type FilingSectionTab = {
|
||||
key: string;
|
||||
label: string;
|
||||
short: string;
|
||||
anchorId: string;
|
||||
};
|
||||
|
||||
export type FilingsViewerHandle = {
|
||||
scrollToAnchor: (anchorId: string) => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
html: string;
|
||||
sections: FilingSectionTab[];
|
||||
activeSection: string;
|
||||
onActiveSectionChange: (key: string) => void;
|
||||
};
|
||||
|
||||
export const FilingsViewer = forwardRef<FilingsViewerHandle, Props>(function FilingsViewer(
|
||||
{ html, sections, activeSection, onActiveSectionChange },
|
||||
ref,
|
||||
) {
|
||||
const scrollRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const activeSectionRef = useRef(activeSection);
|
||||
activeSectionRef.current = activeSection;
|
||||
|
||||
const scrollToAnchor = useCallback((anchorId: string) => {
|
||||
const root = scrollRootRef.current;
|
||||
if (!root) return;
|
||||
const el = root.querySelector(`#${CSS.escape(anchorId)}`);
|
||||
if (el && "scrollIntoView" in el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
scrollToAnchor,
|
||||
}),
|
||||
[scrollToAnchor],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = scrollRootRef.current;
|
||||
if (!root || !html) return;
|
||||
|
||||
const ids = sections.map((s) => s.anchorId).filter(Boolean);
|
||||
const elements = ids
|
||||
.map((id) => root.querySelector(`#${CSS.escape(id)}`))
|
||||
.filter((n): n is Element => n !== null);
|
||||
if (elements.length === 0) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting && e.target.id)
|
||||
.sort((a, b) => {
|
||||
const ra = a.boundingClientRect.top;
|
||||
const rb = b.boundingClientRect.top;
|
||||
return ra - rb;
|
||||
});
|
||||
if (visible.length === 0) return;
|
||||
const top = visible[0]?.target.id;
|
||||
if (!top) return;
|
||||
const sec = sections.find((s) => s.anchorId === top);
|
||||
if (sec && sec.key !== activeSectionRef.current) {
|
||||
onActiveSectionChange(sec.key);
|
||||
}
|
||||
},
|
||||
{
|
||||
root,
|
||||
rootMargin: "-12% 0px -55% 0px",
|
||||
threshold: [0, 0.05, 0.1, 0.25, 0.5],
|
||||
},
|
||||
);
|
||||
|
||||
elements.forEach((el) => observer.observe(el));
|
||||
return () => observer.disconnect();
|
||||
}, [html, sections, onActiveSectionChange]);
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg bg-bg-card overflow-hidden flex flex-col max-h-[min(72vh,calc(100vh-200px))]">
|
||||
<div
|
||||
ref={scrollRootRef}
|
||||
className="overflow-y-auto flex-1 min-h-0 scroll-smooth"
|
||||
style={{ WebkitOverflowScrolling: "touch" }}
|
||||
>
|
||||
<div className="overflow-x-auto min-w-0">
|
||||
<div
|
||||
className="sec-viewer-container max-w-none p-4 md:p-6 pb-10"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
LabelList,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Scatter,
|
||||
ScatterChart,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
export interface QuadrantPoint {
|
||||
id: string;
|
||||
label: string;
|
||||
growth_z: number;
|
||||
inflation_z: number;
|
||||
growth_momentum?: number | null;
|
||||
inflation_momentum?: number | null;
|
||||
quadrant: string;
|
||||
}
|
||||
|
||||
const FLAG: Record<string, string> = {
|
||||
US: "🇺🇸",
|
||||
EU: "🇪🇺",
|
||||
JP: "🇯🇵",
|
||||
CN: "🇨🇳",
|
||||
KR: "🇰🇷",
|
||||
};
|
||||
|
||||
const QUADRANT_COLOR: Record<string, string> = {
|
||||
Reflation: "#FFD93D",
|
||||
Recovery: "#00D4AA",
|
||||
Stagflation: "#FF4757",
|
||||
Overheat: "#4DA6FF",
|
||||
};
|
||||
|
||||
function QuadrantTooltip({
|
||||
active,
|
||||
payload,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: { payload: QuadrantPoint }[];
|
||||
}) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const p = payload[0].payload;
|
||||
const flag = FLAG[p.id] ?? "▪";
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-md px-3 py-2 text-xs font-mono shadow-lg">
|
||||
<div className="text-text-primary font-semibold mb-1">
|
||||
{flag} {p.label}
|
||||
</div>
|
||||
<div className="text-text-muted">Quadrant: {p.quadrant}</div>
|
||||
<div className="text-accent-green">Growth Z: {p.growth_z?.toFixed(2)}</div>
|
||||
<div className="text-accent-blue">Inflation Z: {p.inflation_z?.toFixed(2)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GlobalMacroQuadrantChart({ points }: { points: QuadrantPoint[] }) {
|
||||
if (!points.length) {
|
||||
return (
|
||||
<div className="h-[320px] flex items-center justify-center text-text-muted text-sm font-mono">
|
||||
No quadrant data.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[340px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ScatterChart margin={{ top: 16, right: 16, bottom: 8, left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#2A2A3A" />
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="growth_z"
|
||||
name="Growth Z"
|
||||
stroke="#6B7280"
|
||||
tick={{ fill: "#9CA3AF", fontSize: 11 }}
|
||||
label={{ value: "Growth momentum (Z)", position: "bottom", fill: "#6B7280", fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="inflation_z"
|
||||
name="Inflation Z"
|
||||
stroke="#6B7280"
|
||||
tick={{ fill: "#9CA3AF", fontSize: 11 }}
|
||||
label={{ value: "Inflation momentum (Z)", angle: -90, position: "insideLeft", fill: "#6B7280", fontSize: 11 }}
|
||||
/>
|
||||
<ReferenceLine x={0} stroke="#4B5563" strokeDasharray="4 4" />
|
||||
<ReferenceLine y={0} stroke="#4B5563" strokeDasharray="4 4" />
|
||||
<Tooltip content={<QuadrantTooltip />} cursor={{ strokeDasharray: "3 3" }} />
|
||||
<Scatter data={points} fill="#00D4AA" name="Country">
|
||||
{points.map((entry) => (
|
||||
<Cell key={entry.id} fill={QUADRANT_COLOR[entry.quadrant] ?? "#00D4AA"} />
|
||||
))}
|
||||
<LabelList dataKey="id" position="top" fill="#E5E7EB" fontSize={11} fontFamily="monospace" />
|
||||
</Scatter>
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
export interface CopperGoldRow {
|
||||
date: string;
|
||||
ratio: number;
|
||||
ratio_ma20?: number;
|
||||
}
|
||||
|
||||
function RoroGauge({ z, label }: { z: number | null; label: string | null }) {
|
||||
const v = z == null || Number.isNaN(z) ? 0 : Math.max(-3, Math.min(3, z));
|
||||
const angle = ((v + 3) / 6) * 180;
|
||||
const rad = (angle * Math.PI) / 180;
|
||||
const cx = 100;
|
||||
const cy = 100;
|
||||
const r = 70;
|
||||
const x2 = cx + r * Math.cos(Math.PI - rad);
|
||||
const y2 = cy - r * Math.sin(Math.PI - rad);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-w-[200px]">
|
||||
<p className="text-text-muted text-xs font-mono mb-2">RORO (VIX + bond vol Z)</p>
|
||||
<svg viewBox="0 0 200 110" className="w-52 h-28">
|
||||
<path
|
||||
d="M 30 100 A 70 70 0 0 1 170 100"
|
||||
fill="none"
|
||||
stroke="#2A2A3A"
|
||||
strokeWidth="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M 30 100 A 70 70 0 0 1 170 100"
|
||||
fill="none"
|
||||
stroke="url(#roroGrad)"
|
||||
strokeWidth="10"
|
||||
strokeLinecap="round"
|
||||
opacity={0.35}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="roroGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#FF4757" />
|
||||
<stop offset="50%" stopColor="#FFD93D" />
|
||||
<stop offset="100%" stopColor="#00D4AA" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<line x1={cx} y1={cy} x2={x2} y2={y2} stroke="#F3F4F6" strokeWidth="3" strokeLinecap="round" />
|
||||
<circle cx={cx} cy={cy} r="6" fill="#00D4AA" />
|
||||
<text x="30" y="108" fill="#6B7280" fontSize="9" fontFamily="monospace">
|
||||
Fear
|
||||
</text>
|
||||
<text x="150" y="108" fill="#6B7280" fontSize="9" fontFamily="monospace">
|
||||
Greed
|
||||
</text>
|
||||
</svg>
|
||||
<p className="text-accent-green font-mono text-lg mt-1">{label ?? "—"}</p>
|
||||
<p className="text-text-muted text-xs font-mono">Z = {z != null ? z.toFixed(2) : "—"}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SmartMoneyPanel({
|
||||
roroZ,
|
||||
roroLabel,
|
||||
copperGold,
|
||||
}: {
|
||||
roroZ: number | null;
|
||||
roroLabel: string | null;
|
||||
copperGold: CopperGoldRow[];
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
|
||||
<div className="bg-bg-secondary/40 rounded-lg border border-border/50 p-4 flex items-center justify-center">
|
||||
<RoroGauge z={roroZ} label={roroLabel} />
|
||||
</div>
|
||||
<div className="min-h-[260px]">
|
||||
<p className="text-text-muted text-xs font-mono mb-2">Copper / Gold</p>
|
||||
{!copperGold.length ? (
|
||||
<div className="h-[240px] flex items-center justify-center text-text-muted text-sm font-mono">
|
||||
No copper/gold series.
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<LineChart data={copperGold} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#2A2A3A" />
|
||||
<XAxis dataKey="date" tick={{ fill: "#9CA3AF", fontSize: 9 }} minTickGap={32} />
|
||||
<YAxis tick={{ fill: "#9CA3AF", fontSize: 10 }} domain={["auto", "auto"]} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: "#1A1A26", border: "1px solid #2A2A3A", fontSize: 12 }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Line type="monotone" dataKey="ratio" name="Cu/Au" stroke="#FFD93D" dot={false} strokeWidth={2} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="ratio_ma20"
|
||||
name="MA20"
|
||||
stroke="#4DA6FF"
|
||||
dot={false}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Legend,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
export interface YieldFxRow {
|
||||
date: string;
|
||||
spread_pct: number;
|
||||
fx: number;
|
||||
}
|
||||
|
||||
const PAIR_LABEL: Record<string, { title: string; fx: string }> = {
|
||||
usdjpy: { title: "US 10Y − JP 10Y vs USD/JPY", fx: "USD/JPY" },
|
||||
eurusd: { title: "US 10Y − EZ 10Y vs EUR/USD", fx: "EUR/USD" },
|
||||
usdkrw: { title: "US 10Y − KR 10Y vs USD/KRW", fx: "USD/KRW" },
|
||||
};
|
||||
|
||||
export function YieldFxDualAxisChart({
|
||||
pair,
|
||||
rows,
|
||||
}: {
|
||||
pair: string;
|
||||
rows: YieldFxRow[];
|
||||
}) {
|
||||
const meta = PAIR_LABEL[pair] ?? { title: "Yield spread vs FX", fx: "FX" };
|
||||
|
||||
if (!rows.length) {
|
||||
return (
|
||||
<div className="h-[280px] flex items-center justify-center text-text-muted text-sm font-mono">
|
||||
No yield/FX series.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[300px] w-full">
|
||||
<p className="text-text-muted text-xs font-mono mb-2">{meta.title}</p>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={rows} margin={{ top: 8, right: 12, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#2A2A3A" />
|
||||
<XAxis dataKey="date" tick={{ fill: "#9CA3AF", fontSize: 10 }} minTickGap={24} />
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tick={{ fill: "#00D4AA", fontSize: 10 }}
|
||||
domain={["auto", "auto"]}
|
||||
label={{ value: "Spread (ppt)", angle: -90, position: "insideLeft", fill: "#00D4AA", fontSize: 10 }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fill: "#4DA6FF", fontSize: 10 }}
|
||||
domain={["auto", "auto"]}
|
||||
label={{ value: meta.fx, angle: 90, position: "insideRight", fill: "#4DA6FF", fontSize: 10 }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ background: "#1A1A26", border: "1px solid #2A2A3A", fontSize: 12 }}
|
||||
labelStyle={{ color: "#F3F4F6" }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="spread_pct"
|
||||
name="US10Y − peer (ppt)"
|
||||
stroke="#00D4AA"
|
||||
dot={false}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="fx"
|
||||
name={meta.fx}
|
||||
stroke="#4DA6FF"
|
||||
dot={false}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
|
||||
interface CalendarEvent {
|
||||
datetime: string;
|
||||
country: string;
|
||||
country_flag: string;
|
||||
indicator: string;
|
||||
importance: string;
|
||||
previous: number | null;
|
||||
forecast: number | null;
|
||||
actual: number | null;
|
||||
surprise: number | null;
|
||||
surprise_label: string;
|
||||
}
|
||||
|
||||
interface CalendarData {
|
||||
events: CalendarEvent[];
|
||||
next_high_impact: CalendarEvent | null;
|
||||
total: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const IMPORTANCE_STYLES: Record<string, string> = {
|
||||
high: "bg-accent-red/20 text-accent-red",
|
||||
medium: "bg-accent-yellow/15 text-accent-yellow",
|
||||
low: "bg-bg-primary text-text-muted",
|
||||
};
|
||||
|
||||
const SURPRISE_STYLES: Record<string, string> = {
|
||||
positive: "text-accent-green",
|
||||
negative: "text-accent-red",
|
||||
"in-line": "text-text-muted",
|
||||
pending: "text-text-muted italic",
|
||||
};
|
||||
|
||||
function num(v: number | null): string {
|
||||
if (v == null) return "—";
|
||||
return v.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export function EconomicCalendar() {
|
||||
const [data, setData] = useState<CalendarData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<string>("all");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/macro/economic-calendar?days=7")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((d) => { setData(d); setLoading(false); })
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!data) return [];
|
||||
if (filter === "all") return data.events;
|
||||
return data.events.filter((e) => e.importance === filter);
|
||||
}, [data, filter]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-8 text-center">
|
||||
<div className="text-accent-green animate-pulse font-mono">Loading calendar...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.error) {
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">Economic Calendar</h3>
|
||||
<div className="text-text-muted text-sm">{data?.error || "Calendar data unavailable."}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Next High Impact Banner */}
|
||||
{data.next_high_impact && (
|
||||
<div className="bg-accent-red/10 border border-accent-red/30 rounded-lg p-3 flex items-center gap-3">
|
||||
<div className="text-accent-red text-lg font-bold">!</div>
|
||||
<div>
|
||||
<div className="text-text-primary text-sm font-semibold">
|
||||
Next High-Impact: {data.next_high_impact.country_flag} {data.next_high_impact.indicator}
|
||||
</div>
|
||||
<div className="text-text-muted text-xs">
|
||||
{data.next_high_impact.datetime} | Forecast: {num(data.next_high_impact.forecast)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter */}
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-text-secondary text-sm font-semibold">Economic Calendar</h3>
|
||||
<div className="flex gap-1">
|
||||
{["all", "high", "medium", "low"].map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`text-[11px] px-2.5 py-1 rounded font-medium transition-colors ${
|
||||
filter === f ? "bg-accent-green text-bg-primary" : "text-text-muted hover:text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? "All" : f.charAt(0).toUpperCase() + f.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-text-muted text-sm text-center py-8">No events match the current filter.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[800px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-text-muted text-left">
|
||||
<th className="py-2 pr-2 w-16">Time</th>
|
||||
<th className="py-2 pr-2 w-12"></th>
|
||||
<th className="py-2 pr-2">Event</th>
|
||||
<th className="py-2 pr-2 w-16">Impact</th>
|
||||
<th className="py-2 pr-2 w-20 text-right">Previous</th>
|
||||
<th className="py-2 pr-2 w-20 text-right">Forecast</th>
|
||||
<th className="py-2 pr-2 w-20 text-right">Actual</th>
|
||||
<th className="py-2 w-20 text-right">Surprise</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((evt, idx) => (
|
||||
<tr key={idx} className="border-b border-border/30 hover:bg-bg-primary/30 transition-colors">
|
||||
<td className="py-2 pr-2 text-text-muted font-mono text-xs">{evt.datetime || "—"}</td>
|
||||
<td className="py-2 pr-2 text-center">{evt.country_flag || evt.country}</td>
|
||||
<td className="py-2 pr-2 text-text-primary">{evt.indicator}</td>
|
||||
<td className="py-2 pr-2">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-semibold ${IMPORTANCE_STYLES[evt.importance] || ""}`}>
|
||||
{evt.importance}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-2 text-right font-mono text-text-muted">{num(evt.previous)}</td>
|
||||
<td className="py-2 pr-2 text-right font-mono text-text-secondary">{num(evt.forecast)}</td>
|
||||
<td className={`py-2 pr-2 text-right font-mono font-semibold ${
|
||||
evt.actual != null && evt.forecast != null
|
||||
? evt.actual > evt.forecast ? "text-accent-green" : evt.actual < evt.forecast ? "text-accent-red" : "text-text-primary"
|
||||
: "text-text-primary"
|
||||
}`}>
|
||||
{num(evt.actual)}
|
||||
</td>
|
||||
<td className={`py-2 text-right font-mono text-xs ${SURPRISE_STYLES[evt.surprise_label] || ""}`}>
|
||||
{evt.surprise_label === "pending"
|
||||
? "pending"
|
||||
: evt.surprise != null
|
||||
? `${evt.surprise > 0 ? "+" : ""}${(evt.surprise * 100).toFixed(1)}%`
|
||||
: "—"
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-text-muted text-xs mt-2">{data.total} events total</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface KoreaIndicator {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
prev: number | null;
|
||||
direction: string | null;
|
||||
}
|
||||
|
||||
interface KoreaData {
|
||||
updated_at: string | null;
|
||||
indicators: KoreaIndicator[];
|
||||
source: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function arrow(dir: string | null) {
|
||||
if (dir === "up") return <span className="text-accent-green">↑</span>;
|
||||
if (dir === "down") return <span className="text-accent-red">↓</span>;
|
||||
if (dir === "flat") return <span className="text-text-muted">→</span>;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function KoreaMonitor() {
|
||||
const [data, setData] = useState<KoreaData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const apiKey = typeof window !== "undefined" ? localStorage.getItem("atlas_ecos_key") || "" : "";
|
||||
const params = apiKey ? `?api_key=${encodeURIComponent(apiKey)}` : "";
|
||||
|
||||
fetch(`/api/macro/korea${params}`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((d) => { setData(d); setLoading(false); })
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-8 text-center">
|
||||
<div className="text-accent-green animate-pulse font-mono">Loading Korea data...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.error) {
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">Korea Economic Monitor</h3>
|
||||
<div className="text-text-muted text-sm">{data?.error || "Set an ECOS API key in Settings for full Korean data."}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4">
|
||||
<div className="flex items-end justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-text-secondary text-sm font-semibold">Korea Economic Monitor</h3>
|
||||
<div className="text-text-muted text-xs mt-1">
|
||||
Source: {data.source === "ecos" ? "\ud55c\uad6d\uc740\ud589 ECOS API" : "yfinance (limited)"}
|
||||
</div>
|
||||
</div>
|
||||
{data.source !== "ecos" && (
|
||||
<div className="text-accent-yellow text-xs">
|
||||
Add ECOS API key in Settings for full data
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{data.indicators.map((ind) => (
|
||||
<div key={ind.key} className="bg-bg-primary border border-border rounded-md p-3">
|
||||
<div className="text-text-muted text-xs">{ind.label}</div>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-text-primary font-mono font-bold text-xl">
|
||||
{ind.value.toLocaleString(undefined, { maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
<span className="text-text-muted text-xs">{ind.unit}</span>
|
||||
{arrow(ind.direction)}
|
||||
</div>
|
||||
{ind.prev != null && (
|
||||
<div className="text-text-muted text-[10px] font-mono mt-1">
|
||||
prev {ind.prev.toLocaleString(undefined, { maximumFractionDigits: 2 })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
|
||||
interface MacroIndicator {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number | null;
|
||||
unit: string;
|
||||
zscore: number | null;
|
||||
signal: string;
|
||||
}
|
||||
|
||||
interface CountryHeatmapRow {
|
||||
country: string;
|
||||
region?: string;
|
||||
inflation: number | null;
|
||||
unemployment: number | null;
|
||||
policy_rate: number | null;
|
||||
gdp_growth?: number | null;
|
||||
debt_gdp?: number | null;
|
||||
score: number | null;
|
||||
}
|
||||
|
||||
interface AssetValuationRow {
|
||||
asset: string;
|
||||
symbol: string;
|
||||
price: number;
|
||||
history_mean: number;
|
||||
zscore: number | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface MacroSnapshot {
|
||||
updated_at: string | null;
|
||||
cycle_score: number;
|
||||
regime: string;
|
||||
cycle_heatmap: MacroIndicator[];
|
||||
country_heatmap: CountryHeatmapRow[];
|
||||
asset_valuation: AssetValuationRow[];
|
||||
}
|
||||
|
||||
function heatClass(score: number | null): string {
|
||||
if (score == null) return "bg-bg-primary text-text-muted";
|
||||
if (score >= 0.75) return "bg-accent-green/25 text-accent-green";
|
||||
if (score <= -0.75) return "bg-accent-red/25 text-accent-red";
|
||||
return "bg-accent-yellow/15 text-accent-yellow";
|
||||
}
|
||||
|
||||
function fmt(v: number | null | undefined, suffix = "%"): string {
|
||||
if (v == null) return "—";
|
||||
return `${v.toFixed(2)}${suffix}`;
|
||||
}
|
||||
|
||||
const REGION_ORDER = ["Americas", "Europe", "Asia-Pacific"];
|
||||
|
||||
export function MacroCycleHeatmap({ data }: { data: MacroSnapshot | null }) {
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">Macro Cycle Heatmap</h3>
|
||||
<div className="text-text-muted text-sm">Macro snapshot is unavailable right now.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const grouped: Record<string, CountryHeatmapRow[]> = {};
|
||||
for (const row of data.country_heatmap) {
|
||||
const region = row.region || "Other";
|
||||
(grouped[region] ||= []).push(row);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* US Cycle Indicators */}
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4">
|
||||
<div className="flex items-end justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<h3 className="text-text-secondary text-sm font-semibold">Macro Cycle Heatmap</h3>
|
||||
<div className="text-text-muted text-xs mt-1">
|
||||
FRED-based cycle dashboard for inflation, labor, rates, and production.
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-text-muted text-xs">Current Regime</div>
|
||||
<div className="text-text-primary font-mono font-bold text-lg">{data.regime}</div>
|
||||
<div className={`text-xs font-mono ${data.cycle_score >= 0 ? "text-accent-green" : "text-accent-red"}`}>
|
||||
Cycle Score {data.cycle_score >= 0 ? "+" : ""}{data.cycle_score.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{data.cycle_heatmap.map((item) => (
|
||||
<div key={item.key} className="bg-bg-primary border border-border rounded-md p-3">
|
||||
<div className="text-text-muted text-xs">{item.label}</div>
|
||||
<div className="text-text-primary font-mono font-bold text-xl mt-1">
|
||||
{item.value == null ? "—" : `${item.value.toFixed(2)}${item.unit ? ` ${item.unit}` : ""}`}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<span className={`inline-flex px-2 py-1 rounded text-[11px] font-semibold ${heatClass(item.zscore)}`}>
|
||||
Z {item.zscore == null ? "—" : item.zscore.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Country Heatmap (grouped by region) */}
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">Global Country Heatmap</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-text-muted text-left">
|
||||
<th className="py-2 pr-3">Country</th>
|
||||
<th className="py-2 pr-3">Inflation</th>
|
||||
<th className="py-2 pr-3">Unemployment</th>
|
||||
<th className="py-2 pr-3">Policy Rate</th>
|
||||
<th className="py-2 pr-3">GDP Growth</th>
|
||||
<th className="py-2 pr-3">Debt/GDP</th>
|
||||
<th className="py-2">Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{REGION_ORDER.map((region) => {
|
||||
const rows = grouped[region];
|
||||
if (!rows?.length) return null;
|
||||
return (
|
||||
<Fragment key={region}>
|
||||
<tr>
|
||||
<td colSpan={7} className="pt-3 pb-1 text-[11px] font-semibold text-accent-green tracking-wider uppercase">
|
||||
{region}
|
||||
</td>
|
||||
</tr>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.country} className="border-b border-border/40 hover:bg-bg-primary/30 transition-colors">
|
||||
<td className="py-2 pr-3 text-text-primary">{row.country}</td>
|
||||
<td className="py-2 pr-3 font-mono text-text-primary">{fmt(row.inflation)}</td>
|
||||
<td className="py-2 pr-3 font-mono text-text-primary">{fmt(row.unemployment)}</td>
|
||||
<td className="py-2 pr-3 font-mono text-text-primary">{fmt(row.policy_rate)}</td>
|
||||
<td className="py-2 pr-3 font-mono text-text-primary">{fmt(row.gdp_growth)}</td>
|
||||
<td className="py-2 pr-3 font-mono text-text-primary">{fmt(row.debt_gdp)}</td>
|
||||
<td className="py-2">
|
||||
<span className={`inline-flex px-2 py-1 rounded text-[11px] font-semibold ${heatClass(row.score)}`}>
|
||||
{row.score == null ? "—" : row.score.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Asset Valuation */}
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">Asset Valuation Snapshot</h3>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{data.asset_valuation.map((asset) => (
|
||||
<div key={asset.symbol} className="bg-bg-primary border border-border rounded-md p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-text-primary font-semibold">{asset.asset}</div>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-mono ${heatClass(asset.zscore)}`}>
|
||||
{asset.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-text-muted text-xs mt-1">{asset.symbol}</div>
|
||||
<div className="mt-2 text-text-primary font-mono">${asset.price.toFixed(2)}</div>
|
||||
<div className="text-text-muted text-xs font-mono mt-1">
|
||||
Mean ${asset.history_mean.toFixed(2)} | Z {asset.zscore == null ? "—" : asset.zscore.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
|
||||
interface OECDCountry {
|
||||
country: string;
|
||||
iso: string;
|
||||
cli_current: number | null;
|
||||
cli_prev: number | null;
|
||||
direction: string;
|
||||
bci: number | null;
|
||||
cci: number | null;
|
||||
series: { date: string; value: number }[];
|
||||
}
|
||||
|
||||
export interface OECDData {
|
||||
updated_at: string | null;
|
||||
countries: OECDCountry[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
"#00d4aa", "#ff4757", "#ffa502", "#3742fa", "#a29bfe",
|
||||
"#fd79a8", "#00cec9", "#e17055", "#6c5ce7", "#fdcb6e",
|
||||
];
|
||||
|
||||
function directionBadge(d: string) {
|
||||
const map: Record<string, { label: string; cls: string }> = {
|
||||
expanding: { label: "Expanding", cls: "bg-accent-green/25 text-accent-green" },
|
||||
recovering: { label: "Recovering", cls: "bg-accent-yellow/15 text-accent-yellow" },
|
||||
slowing: { label: "Slowing", cls: "bg-accent-yellow/15 text-accent-yellow" },
|
||||
contracting: { label: "Contracting", cls: "bg-accent-red/25 text-accent-red" },
|
||||
};
|
||||
const info = map[d] || { label: d, cls: "bg-bg-primary text-text-muted" };
|
||||
return <span className={`text-[10px] px-1.5 py-0.5 rounded font-semibold ${info.cls}`}>{info.label}</span>;
|
||||
}
|
||||
|
||||
export function OECDCycleChart({ data }: { data: OECDData | null }) {
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set(["USA", "EA", "JPN", "KOR", "CHN"]));
|
||||
|
||||
const visibleCountries = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return data.countries.filter((c) => selected.has(c.iso));
|
||||
}, [data, selected]);
|
||||
|
||||
if (!data || data.error) {
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">OECD Leading Indicators</h3>
|
||||
<div className="text-text-muted text-sm">{data?.error || "Loading OECD data..."}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const allDates: string[] = [];
|
||||
const dateSet = new Set<string>();
|
||||
for (const c of visibleCountries) {
|
||||
for (const pt of c.series) {
|
||||
if (!dateSet.has(pt.date)) {
|
||||
dateSet.add(pt.date);
|
||||
allDates.push(pt.date);
|
||||
}
|
||||
}
|
||||
}
|
||||
allDates.sort();
|
||||
|
||||
let minVal = 95, maxVal = 105;
|
||||
for (const c of visibleCountries) {
|
||||
for (const pt of c.series) {
|
||||
if (pt.value < minVal) minVal = pt.value;
|
||||
if (pt.value > maxVal) maxVal = pt.value;
|
||||
}
|
||||
}
|
||||
const padding = (maxVal - minVal) * 0.1 || 1;
|
||||
minVal -= padding;
|
||||
maxVal += padding;
|
||||
|
||||
const W = 800, H = 280, PL = 50, PR = 20, PT = 10, PB = 30;
|
||||
const chartW = W - PL - PR;
|
||||
const chartH = H - PT - PB;
|
||||
|
||||
function x(idx: number) { return PL + (allDates.length > 1 ? (idx / (allDates.length - 1)) * chartW : chartW / 2); }
|
||||
function y(val: number) { return PT + chartH - ((val - minVal) / (maxVal - minVal)) * chartH; }
|
||||
|
||||
const baseline100Y = y(100);
|
||||
|
||||
function toggle(iso: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(iso)) next.delete(iso);
|
||||
else next.add(iso);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4">
|
||||
<div className="flex items-end justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-text-secondary text-sm font-semibold">OECD Leading Indicators (CLI)</h3>
|
||||
<div className="text-text-muted text-xs mt-1">100 baseline = long-term trend. Above 100 = expansion phase.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Country Toggle */}
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{data.countries.map((c, i) => (
|
||||
<button
|
||||
key={c.iso}
|
||||
onClick={() => toggle(c.iso)}
|
||||
className={`text-[11px] px-2 py-1 rounded font-medium border transition-colors ${
|
||||
selected.has(c.iso)
|
||||
? "border-transparent text-bg-primary"
|
||||
: "border-border text-text-muted hover:text-text-secondary"
|
||||
}`}
|
||||
style={selected.has(c.iso) ? { backgroundColor: COLORS[i % COLORS.length] } : {}}
|
||||
>
|
||||
{c.iso}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* SVG Chart */}
|
||||
{allDates.length > 1 ? (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" preserveAspectRatio="xMidYMid meet">
|
||||
{/* Grid lines */}
|
||||
{[minVal, 100, maxVal].map((v) => (
|
||||
<line key={v} x1={PL} x2={W - PR} y1={y(v)} y2={y(v)} stroke="currentColor" strokeOpacity={0.1} />
|
||||
))}
|
||||
{/* 100 baseline */}
|
||||
<line x1={PL} x2={W - PR} y1={baseline100Y} y2={baseline100Y}
|
||||
stroke="#00d4aa" strokeOpacity={0.3} strokeDasharray="4 2" />
|
||||
<text x={PL - 4} y={baseline100Y + 4} textAnchor="end" fill="#00d4aa" fontSize="10" opacity={0.6}>100</text>
|
||||
|
||||
{/* Lines */}
|
||||
{visibleCountries.map((c) => {
|
||||
const colorIdx = data.countries.findIndex((dc) => dc.iso === c.iso);
|
||||
const color = COLORS[colorIdx % COLORS.length];
|
||||
const dateMap = new Map(c.series.map((pt) => [pt.date, pt.value]));
|
||||
const points = allDates
|
||||
.map((d, i) => ({ x: x(i), y: dateMap.has(d) ? y(dateMap.get(d)!) : null }))
|
||||
.filter((p) => p.y !== null) as { x: number; y: number }[];
|
||||
if (points.length < 2) return null;
|
||||
const d = points.map((p, i) => `${i === 0 ? "M" : "L"}${p.x},${p.y}`).join(" ");
|
||||
return <path key={c.iso} d={d} fill="none" stroke={color} strokeWidth={1.8} />;
|
||||
})}
|
||||
|
||||
{/* X-axis labels */}
|
||||
{allDates.filter((_, i) => i % Math.max(1, Math.floor(allDates.length / 6)) === 0).map((d) => {
|
||||
const idx = allDates.indexOf(d);
|
||||
return (
|
||||
<text key={d} x={x(idx)} y={H - 5} textAnchor="middle" fill="currentColor" fontSize="9" opacity={0.5}>
|
||||
{d}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
) : (
|
||||
<div className="h-40 flex items-center justify-center text-text-muted text-sm">No chart data available</div>
|
||||
)}
|
||||
|
||||
{/* Country Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-2 mt-3">
|
||||
{data.countries.filter((c) => selected.has(c.iso)).map((c) => (
|
||||
<div key={c.iso} className="bg-bg-primary border border-border rounded-md p-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-text-primary text-xs font-semibold">{c.iso}</span>
|
||||
{directionBadge(c.direction)}
|
||||
</div>
|
||||
<div className="text-text-primary font-mono font-bold text-lg mt-1">
|
||||
{c.cli_current?.toFixed(2) ?? "—"}
|
||||
</div>
|
||||
{c.cli_prev != null && (
|
||||
<div className={`text-[10px] font-mono ${(c.cli_current ?? 0) >= c.cli_prev ? "text-accent-green" : "text-accent-red"}`}>
|
||||
prev {c.cli_prev.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,8 @@ interface CommodityOverviewProps {
|
||||
}
|
||||
|
||||
export function CommodityOverview({ ticker, data }: CommodityOverviewProps) {
|
||||
const seasonal = data?.seasonal_pattern || {};
|
||||
const correlations = data?.correlation_matrix || {};
|
||||
const seasonal = (data?.seasonal_pattern ?? {}) as Record<string, unknown>;
|
||||
const correlations = (data?.correlation_matrix ?? {}) as Record<string, unknown>;
|
||||
const related = Array.isArray(data?.related_assets) ? data.related_assets : [];
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -15,7 +15,7 @@ export function CommodityOverview({ ticker, data }: CommodityOverviewProps) {
|
||||
<span className="text-accent-green">{ticker}</span> Commodity Overview
|
||||
</h1>
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5">
|
||||
<div className="text-text-primary font-semibold text-lg">{data?.name || ticker}</div>
|
||||
<div className="text-text-primary font-semibold text-lg">{String(data?.name ?? ticker)}</div>
|
||||
<div className="text-3xl font-mono font-bold text-text-primary mt-1">
|
||||
{data?.price != null ? `$${Number(data.price).toFixed(2)}` : "—"}
|
||||
</div>
|
||||
@@ -39,7 +39,7 @@ export function CommodityOverview({ ticker, data }: CommodityOverviewProps) {
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">Seasonal Pattern (10Y avg monthly)</h3>
|
||||
<div className="grid grid-cols-3 lg:grid-cols-6 gap-2 text-xs">
|
||||
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => {
|
||||
const v = seasonal?.[m] ?? 0;
|
||||
const v = Number(seasonal[String(m)] ?? 0) || 0;
|
||||
return (
|
||||
<div key={m} className="bg-bg-primary border border-border rounded p-2">
|
||||
<div className="text-text-muted">M{m}</div>
|
||||
@@ -57,13 +57,13 @@ export function CommodityOverview({ ticker, data }: CommodityOverviewProps) {
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">Related Assets</h3>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2">
|
||||
{related.map((r: Record<string, unknown>) => (
|
||||
<div key={r.symbol} className="bg-bg-primary border border-border rounded p-3">
|
||||
<div className="text-text-secondary text-xs">{r.symbol}</div>
|
||||
{related.map((r: Record<string, unknown>, idx: number) => (
|
||||
<div key={String(r.symbol ?? idx)} className="bg-bg-primary border border-border rounded p-3">
|
||||
<div className="text-text-secondary text-xs">{String(r.symbol ?? "")}</div>
|
||||
<div className="text-text-primary font-mono">{r.price != null ? `$${Number(r.price).toFixed(2)}` : "—"}</div>
|
||||
<div className={`text-xs font-mono ${Number(r.change_pct || 0) >= 0 ? "text-accent-green" : "text-accent-red"}`}>
|
||||
{Number(r.change_pct || 0) >= 0 ? "+" : ""}
|
||||
{r.change_pct ?? 0}%
|
||||
{Number(r.change_pct ?? 0)}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -6,32 +6,33 @@ interface ETFOverviewProps {
|
||||
}
|
||||
|
||||
export function ETFOverview({ ticker, data }: ETFOverviewProps) {
|
||||
const returns = data?.returns || {};
|
||||
const risk = data?.risk || {};
|
||||
const returns = (data?.returns ?? {}) as Record<string, unknown>;
|
||||
const risk = (data?.risk ?? {}) as Record<string, unknown>;
|
||||
const holdings = Array.isArray(data?.holdings) ? data.holdings : [];
|
||||
const metrics: { label: string; value: string }[] = [
|
||||
{ label: "Category", value: String(data?.category ?? "N/A") },
|
||||
{ label: "AUM", value: data?.aum ? `$${(Number(data.aum) / 1e9).toFixed(1)}B` : "—" },
|
||||
{ label: "Expense Ratio", value: data?.expense_ratio != null ? `${(Number(data.expense_ratio) * 100).toFixed(2)}%` : "—" },
|
||||
{ label: "NAV", value: data?.nav != null ? `$${Number(data.nav).toFixed(2)}` : "—" },
|
||||
{ label: "52W High", value: data?.high_52w != null ? `$${Number(data.high_52w).toFixed(2)}` : "—" },
|
||||
{ label: "52W Low", value: data?.low_52w != null ? `$${Number(data.low_52w).toFixed(2)}` : "—" },
|
||||
{ label: "1Y Return", value: returns["1y"] != null ? `${returns["1y"]}%` : "—" },
|
||||
{ label: "YTD Return", value: returns.ytd != null ? `${returns.ytd}%` : "—" },
|
||||
];
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">
|
||||
<span className="text-accent-green">{ticker}</span> ETF Overview
|
||||
</h1>
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5">
|
||||
<div className="text-text-primary font-semibold text-lg">{data?.name || ticker}</div>
|
||||
<div className="text-text-primary font-semibold text-lg">{String(data?.name ?? ticker)}</div>
|
||||
<div className="text-3xl font-mono font-bold text-text-primary mt-1">
|
||||
{data?.price != null ? `$${Number(data.price).toFixed(2)}` : "—"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "Category", value: data?.category || "N/A" },
|
||||
{ label: "AUM", value: data?.aum ? `$${(Number(data.aum) / 1e9).toFixed(1)}B` : "—" },
|
||||
{ label: "Expense Ratio", value: data?.expense_ratio != null ? `${(Number(data.expense_ratio) * 100).toFixed(2)}%` : "—" },
|
||||
{ label: "NAV", value: data?.nav != null ? `$${Number(data.nav).toFixed(2)}` : "—" },
|
||||
{ label: "52W High", value: data?.high_52w != null ? `$${Number(data.high_52w).toFixed(2)}` : "—" },
|
||||
{ label: "52W Low", value: data?.low_52w != null ? `$${Number(data.low_52w).toFixed(2)}` : "—" },
|
||||
{ label: "1Y Return", value: returns?.["1y"] != null ? `${returns["1y"]}%` : "—" },
|
||||
{ label: "YTD Return", value: returns?.ytd != null ? `${returns.ytd}%` : "—" },
|
||||
].map((m) => (
|
||||
{metrics.map((m) => (
|
||||
<div key={m.label} className="bg-bg-card border border-border rounded-lg p-4">
|
||||
<div className="text-text-muted text-xs">{m.label}</div>
|
||||
<div className="text-text-primary font-mono font-semibold mt-1">{m.value}</div>
|
||||
@@ -44,7 +45,7 @@ export function ETFOverview({ ticker, data }: ETFOverviewProps) {
|
||||
<div className="flex flex-wrap gap-2 text-sm">
|
||||
{["1m", "3m", "6m", "ytd", "1y", "3y", "5y"].map((k) => (
|
||||
<span key={k} className="bg-bg-primary border border-border rounded px-2 py-1 font-mono text-text-primary">
|
||||
{k.toUpperCase()}: {returns?.[k] != null ? `${returns[k]}%` : "—"}
|
||||
{k.toUpperCase()}: {returns[k] != null ? `${String(returns[k])}%` : "—"}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -55,8 +56,8 @@ export function ETFOverview({ ticker, data }: ETFOverviewProps) {
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">Top Holdings</h3>
|
||||
<div className="space-y-2">
|
||||
{holdings.slice(0, 10).map((h: Record<string, unknown>, i: number) => (
|
||||
<div key={`${h.symbol || h.name}-${i}`} className="flex justify-between text-sm">
|
||||
<span className="text-text-primary">{h.symbol || h.name || "—"}</span>
|
||||
<div key={`${String(h.symbol ?? h.name ?? i)}-${i}`} className="flex justify-between text-sm">
|
||||
<span className="text-text-primary">{String(h.symbol ?? h.name ?? "—")}</span>
|
||||
<span className="text-text-muted font-mono">
|
||||
{h.weight_pct != null ? `${(Number(h.weight_pct) * 100).toFixed(2)}%` : "—"}
|
||||
</span>
|
||||
@@ -67,12 +68,14 @@ export function ETFOverview({ ticker, data }: ETFOverviewProps) {
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "Sharpe", value: risk?.sharpe },
|
||||
{ label: "Sortino", value: risk?.sortino },
|
||||
{ label: "Max DD", value: risk?.max_drawdown != null ? `${risk.max_drawdown}%` : null },
|
||||
{ label: "Volatility", value: risk?.volatility != null ? `${risk.volatility}%` : null },
|
||||
].map((r) => (
|
||||
{(
|
||||
[
|
||||
{ label: "Sharpe", value: risk.sharpe != null ? String(risk.sharpe) : null },
|
||||
{ label: "Sortino", value: risk.sortino != null ? String(risk.sortino) : null },
|
||||
{ label: "Max DD", value: risk.max_drawdown != null ? `${risk.max_drawdown}%` : null },
|
||||
{ label: "Volatility", value: risk.volatility != null ? `${risk.volatility}%` : null },
|
||||
] as { label: string; value: string | null }[]
|
||||
).map((r) => (
|
||||
<div key={r.label} className="bg-bg-card border border-border rounded-lg p-4">
|
||||
<div className="text-text-muted text-xs">{r.label}</div>
|
||||
<div className="text-text-primary font-mono font-semibold mt-1">{r.value ?? "—"}</div>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { KpiSection, type KpiHistoryData } from "./KpiSection";
|
||||
import { PeerComparison, type PeerComparisonData } from "./PeerComparison";
|
||||
|
||||
interface EquityOverviewProps {
|
||||
ticker: string;
|
||||
sector: Record<string, unknown> | null;
|
||||
@@ -7,9 +11,27 @@ interface EquityOverviewProps {
|
||||
}
|
||||
|
||||
export function EquityOverview({ ticker, sector, health }: EquityOverviewProps) {
|
||||
const metrics = [
|
||||
{ label: "Sector", value: sector?.sector || "—" },
|
||||
{ label: "Industry", value: sector?.industry || "—" },
|
||||
const [peerData, setPeerData] = useState<PeerComparisonData | null>(null);
|
||||
const [kpiData, setKpiData] = useState<KpiHistoryData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
Promise.all([
|
||||
fetch(`/api/market/peers/${encodeURIComponent(ticker)}`).then((r) => (r.ok ? r.json() : null)),
|
||||
fetch(`/api/financials/${encodeURIComponent(ticker)}/kpi-history`).then((r) => (r.ok ? r.json() : null)),
|
||||
]).then(([p, k]) => {
|
||||
if (!cancelled) {
|
||||
setPeerData(p && Array.isArray(p.peers) ? p : null);
|
||||
setKpiData(k && Array.isArray(k.quarters) ? k : null);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [ticker]);
|
||||
const metrics: { label: string; value: string }[] = [
|
||||
{ label: "Sector", value: String(sector?.sector ?? "—") },
|
||||
{ label: "Industry", value: String(sector?.industry ?? "—") },
|
||||
{ label: "Market Cap", value: sector?.market_cap ? `$${(Number(sector.market_cap) / 1e9).toFixed(1)}B` : "—" },
|
||||
{ label: "P/E Ratio", value: sector?.pe_ratio != null ? Number(sector.pe_ratio).toFixed(1) : "—" },
|
||||
{ label: "Beta", value: sector?.beta != null ? Number(sector.beta).toFixed(2) : "—" },
|
||||
@@ -34,6 +56,7 @@ export function EquityOverview({ ticker, sector, health }: EquityOverviewProps)
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<KpiSection data={kpiData} />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-4">
|
||||
<Card title="Altman Z-Score" value={health?.altman_z != null ? Number(health.altman_z).toFixed(2) : "—"} />
|
||||
<Card title="Current Ratio" value={health?.current_ratio != null ? Number(health.current_ratio).toFixed(2) : "—"} />
|
||||
@@ -42,13 +65,13 @@ export function EquityOverview({ ticker, sector, health }: EquityOverviewProps)
|
||||
</div>
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">DuPont Analysis</h3>
|
||||
{!!health?.dupont ? (
|
||||
{!!health?.dupont && typeof health.dupont === "object" && health.dupont !== null ? (
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ label: "ROE", value: health.dupont.roe },
|
||||
{ label: "Net Profit Margin", value: health.dupont.npm },
|
||||
{ label: "Asset Turnover", value: health.dupont.asset_turnover },
|
||||
{ label: "Equity Multiplier", value: health.dupont.equity_multiplier },
|
||||
{ label: "ROE", value: (health.dupont as { roe?: unknown }).roe },
|
||||
{ label: "Net Profit Margin", value: (health.dupont as { npm?: unknown }).npm },
|
||||
{ label: "Asset Turnover", value: (health.dupont as { asset_turnover?: unknown }).asset_turnover },
|
||||
{ label: "Equity Multiplier", value: (health.dupont as { equity_multiplier?: unknown }).equity_multiplier },
|
||||
].map((d) => (
|
||||
<div key={d.label} className="flex justify-between">
|
||||
<span className="text-text-muted text-sm">{d.label}</span>
|
||||
@@ -60,6 +83,7 @@ export function EquityOverview({ ticker, sector, health }: EquityOverviewProps)
|
||||
<div className="text-text-muted">No data</div>
|
||||
)}
|
||||
</div>
|
||||
<PeerComparison currentTicker={ticker} data={peerData} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
export interface KpiHistoryData {
|
||||
ticker: string;
|
||||
quarters: string[];
|
||||
revenue_growth: Array<number | null>;
|
||||
operating_margin: Array<number | null>;
|
||||
net_margin: Array<number | null>;
|
||||
roe: Array<number | null>;
|
||||
fcf: Array<number | null>;
|
||||
}
|
||||
|
||||
function formatMetricValue(key: string, value: number | null): string {
|
||||
if (value == null) return "—";
|
||||
if (key === "fcf") {
|
||||
const abs = Math.abs(value);
|
||||
if (abs >= 1e9) return `$${(value / 1e9).toFixed(1)}B`;
|
||||
if (abs >= 1e6) return `$${(value / 1e6).toFixed(1)}M`;
|
||||
return `$${value.toFixed(0)}`;
|
||||
}
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatDelta(value: number | null, previous: number | null, key: string): string {
|
||||
if (value == null || previous == null) return "—";
|
||||
if (key === "fcf") {
|
||||
const delta = value - previous;
|
||||
const abs = Math.abs(delta);
|
||||
if (abs >= 1e9) return `${delta >= 0 ? "+" : ""}$${(delta / 1e9).toFixed(1)}B`;
|
||||
if (abs >= 1e6) return `${delta >= 0 ? "+" : ""}$${(delta / 1e6).toFixed(1)}M`;
|
||||
return `${delta >= 0 ? "+" : ""}$${delta.toFixed(0)}`;
|
||||
}
|
||||
const delta = value - previous;
|
||||
return `${delta >= 0 ? "+" : ""}${delta.toFixed(1)}pp`;
|
||||
}
|
||||
|
||||
function Sparkline({ values }: { values: Array<number | null> }) {
|
||||
const points = values
|
||||
.map((value, index) => ({ value, index }))
|
||||
.filter((item): item is { value: number; index: number } => item.value != null);
|
||||
|
||||
if (points.length < 2) {
|
||||
return <div className="h-16 rounded-md bg-bg-primary/60 border border-border" />;
|
||||
}
|
||||
|
||||
const width = 180;
|
||||
const height = 64;
|
||||
const min = Math.min(...points.map((p) => p.value));
|
||||
const max = Math.max(...points.map((p) => p.value));
|
||||
const range = max - min || 1;
|
||||
const step = width / Math.max(points.length - 1, 1);
|
||||
const path = points
|
||||
.map((point, idx) => {
|
||||
const x = idx * step;
|
||||
const y = height - ((point.value - min) / range) * (height - 10) - 5;
|
||||
return `${idx === 0 ? "M" : "L"}${x.toFixed(2)} ${y.toFixed(2)}`;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
const last = points[points.length - 1];
|
||||
const lastX = (points.length - 1) * step;
|
||||
const lastY = height - ((last.value - min) / range) * (height - 10) - 5;
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-16">
|
||||
<path d={path} fill="none" stroke="#00D4AA" strokeWidth="2" strokeLinecap="round" />
|
||||
<circle cx={lastX} cy={lastY} r="3" fill="#00D4AA" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function KpiSection({ data }: { data: KpiHistoryData | null }) {
|
||||
if (!data || data.quarters.length === 0) {
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5 mb-6">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">Key Performance Indicators</h3>
|
||||
<div className="text-text-muted text-sm">Quarterly KPI history is not available for this ticker.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cards = [
|
||||
{ key: "revenue_growth", label: "Revenue QoQ", values: data.revenue_growth },
|
||||
{ key: "operating_margin", label: "Operating Margin", values: data.operating_margin },
|
||||
{ key: "net_margin", label: "Net Margin", values: data.net_margin },
|
||||
{ key: "roe", label: "ROE", values: data.roe },
|
||||
{ key: "fcf", label: "Free Cash Flow", values: data.fcf },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">Key Performance Indicators</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{cards.map((card) => {
|
||||
const latest = card.values[card.values.length - 1] ?? null;
|
||||
const previous = card.values.length > 1 ? card.values[card.values.length - 2] ?? null : null;
|
||||
const delta = latest != null && previous != null ? latest - previous : null;
|
||||
|
||||
return (
|
||||
<div key={card.key} className="bg-bg-card border border-border rounded-lg p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<div className="text-text-muted text-xs">{card.label}</div>
|
||||
<div className="text-text-primary text-2xl font-mono font-bold mt-1">
|
||||
{formatMetricValue(card.key, latest)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded font-semibold ${
|
||||
delta == null
|
||||
? "bg-bg-primary text-text-muted"
|
||||
: delta >= 0
|
||||
? "bg-accent-green/15 text-accent-green"
|
||||
: "bg-accent-red/15 text-accent-red"
|
||||
}`}
|
||||
>
|
||||
{formatDelta(latest, previous, card.key)}
|
||||
</span>
|
||||
</div>
|
||||
<Sparkline values={card.values} />
|
||||
<div className="mt-2 text-[11px] text-text-muted font-mono">
|
||||
{data.quarters.slice(Math.max(0, data.quarters.length - 4)).join(" ")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
export interface PeerItem {
|
||||
ticker: string;
|
||||
name: string;
|
||||
market_cap: number | null;
|
||||
pe: number | null;
|
||||
pb: number | null;
|
||||
ps: number | null;
|
||||
ev_ebitda: number | null;
|
||||
}
|
||||
|
||||
export interface PeerComparisonData {
|
||||
ticker: string;
|
||||
sector: string;
|
||||
industry: string;
|
||||
averages: {
|
||||
pe: number | null;
|
||||
pb: number | null;
|
||||
ps: number | null;
|
||||
ev_ebitda: number | null;
|
||||
};
|
||||
peers: PeerItem[];
|
||||
}
|
||||
|
||||
function formatValue(value: number | null, type: "multiple" | "marketCap" = "multiple"): string {
|
||||
if (value == null) return "—";
|
||||
if (type === "marketCap") {
|
||||
if (value >= 1e12) return `$${(value / 1e12).toFixed(2)}T`;
|
||||
if (value >= 1e9) return `$${(value / 1e9).toFixed(1)}B`;
|
||||
if (value >= 1e6) return `$${(value / 1e6).toFixed(1)}M`;
|
||||
return `$${value.toFixed(0)}`;
|
||||
}
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
function extrema(values: Array<number | null>) {
|
||||
const numbers = values.filter((value): value is number => value != null);
|
||||
return {
|
||||
min: numbers.length ? Math.min(...numbers) : null,
|
||||
max: numbers.length ? Math.max(...numbers) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function valueClass(value: number | null, min: number | null, max: number | null): string {
|
||||
if (value == null) return "text-text-muted";
|
||||
if (min != null && value === min) return "text-accent-green";
|
||||
if (max != null && value === max) return "text-accent-red";
|
||||
return "text-text-primary";
|
||||
}
|
||||
|
||||
export function PeerComparison({
|
||||
currentTicker,
|
||||
data,
|
||||
}: {
|
||||
currentTicker: string;
|
||||
data: PeerComparisonData | null;
|
||||
}) {
|
||||
if (!data || data.peers.length === 0) {
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5 mt-6">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-2">Valuation vs. Peers</h3>
|
||||
<div className="text-text-muted text-sm">Peer comparison data is not available for this ticker.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const peExtrema = extrema(data.peers.map((peer) => peer.pe));
|
||||
const pbExtrema = extrema(data.peers.map((peer) => peer.pb));
|
||||
const psExtrema = extrema(data.peers.map((peer) => peer.ps));
|
||||
const evEbitdaExtrema = extrema(data.peers.map((peer) => peer.ev_ebitda));
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5 mt-6">
|
||||
<div className="flex items-end justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<h3 className="text-text-secondary text-sm font-semibold">Valuation vs. Peers</h3>
|
||||
<div className="text-text-muted text-xs mt-1">{data.industry || data.sector || "Industry peers"}</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-text-muted font-mono flex gap-3">
|
||||
<span>Avg PE: {formatValue(data.averages.pe)}</span>
|
||||
<span>Avg PB: {formatValue(data.averages.pb)}</span>
|
||||
<span>Avg PS: {formatValue(data.averages.ps)}</span>
|
||||
<span>Avg EV/EBITDA: {formatValue(data.averages.ev_ebitda)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[760px] text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-text-muted border-b border-border">
|
||||
<th className="py-3 pr-3 font-medium">Ticker</th>
|
||||
<th className="py-3 pr-3 font-medium">Company</th>
|
||||
<th className="py-3 pr-3 font-medium">Market Cap</th>
|
||||
<th className="py-3 pr-3 font-medium">P/E</th>
|
||||
<th className="py-3 pr-3 font-medium">P/B</th>
|
||||
<th className="py-3 pr-3 font-medium">P/S</th>
|
||||
<th className="py-3 font-medium">EV/EBITDA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.peers.map((peer) => {
|
||||
const isCurrent = peer.ticker.toUpperCase() === currentTicker.toUpperCase();
|
||||
return (
|
||||
<tr
|
||||
key={peer.ticker}
|
||||
className={`border-b border-border/60 ${isCurrent ? "bg-accent-green/10" : ""}`}
|
||||
>
|
||||
<td className="py-3 pr-3 font-mono font-semibold text-text-primary">{peer.ticker}</td>
|
||||
<td className="py-3 pr-3 text-text-secondary">{peer.name}</td>
|
||||
<td className="py-3 pr-3 font-mono text-text-primary">{formatValue(peer.market_cap, "marketCap")}</td>
|
||||
<td className={`py-3 pr-3 font-mono ${valueClass(peer.pe, peExtrema.min, peExtrema.max)}`}>
|
||||
{formatValue(peer.pe)}
|
||||
</td>
|
||||
<td className={`py-3 pr-3 font-mono ${valueClass(peer.pb, pbExtrema.min, pbExtrema.max)}`}>
|
||||
{formatValue(peer.pb)}
|
||||
</td>
|
||||
<td className={`py-3 pr-3 font-mono ${valueClass(peer.ps, psExtrema.min, psExtrema.max)}`}>
|
||||
{formatValue(peer.ps)}
|
||||
</td>
|
||||
<td className={`py-3 font-mono ${valueClass(peer.ev_ebitda, evEbitdaExtrema.min, evEbitdaExtrema.max)}`}>
|
||||
{formatValue(peer.ev_ebitda)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AnomalyExplainResult, FinancialAnomalyItem } from "./types";
|
||||
|
||||
type FilingFocus = "10k_mda" | "risk";
|
||||
|
||||
export function AnomalyChips({
|
||||
ticker,
|
||||
anomalies,
|
||||
}: {
|
||||
ticker: string;
|
||||
anomalies: FinancialAnomalyItem[];
|
||||
}) {
|
||||
const [focus, setFocus] = useState<FilingFocus>("10k_mda");
|
||||
const [secEmail, setSecEmail] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [explain, setExplain] = useState<AnomalyExplainResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const v = localStorage.getItem("atlas_sec_email") || "";
|
||||
if (v) setSecEmail(v);
|
||||
}, []);
|
||||
|
||||
async function onChipClick(a: FinancialAnomalyItem) {
|
||||
const apiKey = typeof window !== "undefined" ? localStorage.getItem("atlas_gemini_key") || "" : "";
|
||||
if (!apiKey) {
|
||||
setError("Settings에서 Gemini API 키를 저장한 뒤 다시 시도하세요.");
|
||||
setExplain(null);
|
||||
return;
|
||||
}
|
||||
const email = secEmail.trim() || (typeof window !== "undefined" ? localStorage.getItem("atlas_sec_email") || "" : "");
|
||||
if (!email.trim()) {
|
||||
setError("SEC 공정 이용 이메일을 입력하거나 localStorage `atlas_sec_email`을 설정하세요.");
|
||||
setExplain(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setExplain(null);
|
||||
try {
|
||||
const res = await fetch("/api/analysis/anomaly-explain", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ticker,
|
||||
api_key: apiKey,
|
||||
sec_email: email.trim(),
|
||||
account_key: a.account_key,
|
||||
display_name: a.display_name,
|
||||
direction: a.direction,
|
||||
magnitude_pct: Math.abs(a.change_pct ?? 0),
|
||||
filing_focus: focus,
|
||||
}),
|
||||
});
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const d = (payload as { detail?: unknown }).detail;
|
||||
const msg =
|
||||
typeof d === "string"
|
||||
? d
|
||||
: Array.isArray(d)
|
||||
? d.map((x: { msg?: string }) => x.msg).filter(Boolean).join("; ")
|
||||
: "";
|
||||
setError(msg || `API ${res.status}`);
|
||||
return;
|
||||
}
|
||||
const data = payload as {
|
||||
summary?: string;
|
||||
likely_causes?: unknown;
|
||||
citations?: unknown;
|
||||
confidence?: string;
|
||||
};
|
||||
setExplain({
|
||||
summary: data.summary || "",
|
||||
likely_causes: Array.isArray(data.likely_causes) ? (data.likely_causes as string[]) : [],
|
||||
citations: Array.isArray(data.citations)
|
||||
? (data.citations as { excerpt: string; context: string }[])
|
||||
: [],
|
||||
confidence: data.confidence || "medium",
|
||||
});
|
||||
} catch {
|
||||
setError("네트워크 오류");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function persistEmail() {
|
||||
if (typeof window === "undefined") return;
|
||||
const t = secEmail.trim();
|
||||
if (t) localStorage.setItem("atlas_sec_email", t);
|
||||
else localStorage.removeItem("atlas_sec_email");
|
||||
}
|
||||
|
||||
if (!anomalies.length) {
|
||||
return (
|
||||
<div className="text-text-muted text-sm">
|
||||
YoY 변동이 임계값(30%)을 넘는 계정이 없습니다.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-text-muted text-xs">Filing</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFocus("10k_mda")}
|
||||
className={`text-xs px-2 py-1 rounded border ${focus === "10k_mda" ? "border-accent-green text-accent-green" : "border-border text-text-muted"}`}
|
||||
>
|
||||
Item 7 (MD&A)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFocus("risk")}
|
||||
className={`text-xs px-2 py-1 rounded border ${focus === "risk" ? "border-accent-green text-accent-green" : "border-border text-text-muted"}`}
|
||||
>
|
||||
Risk (1A / 9A)
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 items-end">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="text-text-muted text-[10px] block mb-1">SEC fair-access email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={secEmail}
|
||||
onChange={(e) => setSecEmail(e.target.value)}
|
||||
onBlur={persistEmail}
|
||||
placeholder="name@company.com"
|
||||
className="w-full bg-bg-primary border border-border rounded-md px-2 py-1.5 text-sm text-text-primary outline-none focus:border-accent-green"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{anomalies.map((a) => {
|
||||
const up = (a.direction || "").toLowerCase() === "up";
|
||||
const pct = a.change_pct ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={`${a.account_key}-${a.display_name}`}
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => onChipClick(a)}
|
||||
className={`text-xs font-mono px-3 py-1.5 rounded-full border transition-opacity ${
|
||||
up ? "border-accent-green/60 text-accent-green" : "border-accent-red/60 text-accent-red"
|
||||
} hover:opacity-90 disabled:opacity-40 bg-bg-primary/60`}
|
||||
>
|
||||
{a.display_name} {up ? "▲" : "▼"} {Math.abs(pct).toFixed(1)}%
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{loading && <div className="text-accent-green text-sm animate-pulse font-mono">Explaining…</div>}
|
||||
{error && <div className="text-accent-red text-sm">{error}</div>}
|
||||
{explain && (
|
||||
<div className="rounded-lg border border-border bg-bg-primary/40 p-4 space-y-3">
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<span className="text-text-muted text-xs">Confidence</span>
|
||||
<span className="text-xs font-mono text-accent-green">{explain.confidence}</span>
|
||||
</div>
|
||||
<p className="text-text-primary text-sm leading-relaxed">{explain.summary}</p>
|
||||
{explain.likely_causes.length > 0 && (
|
||||
<ul className="list-disc list-inside text-sm text-text-secondary space-y-1">
|
||||
{explain.likely_causes.map((c, i) => (
|
||||
<li key={i}>{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{explain.citations.length > 0 && (
|
||||
<div className="space-y-2 border-t border-border pt-3">
|
||||
<div className="text-text-muted text-xs">Citations</div>
|
||||
{explain.citations.map((c, i) => (
|
||||
<blockquote key={i} className="text-xs text-text-secondary border-l-2 border-accent-green/40 pl-2">
|
||||
<span className="text-text-muted">{c.context}</span>
|
||||
<p className="mt-1 italic">{c.excerpt}</p>
|
||||
</blockquote>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import type { DuPontTreePayload, DuPontTreeNode } from "./types";
|
||||
|
||||
function trendClass(t: string): string {
|
||||
if (t === "up") return "text-accent-green";
|
||||
if (t === "down") return "text-accent-red";
|
||||
return "text-text-secondary";
|
||||
}
|
||||
|
||||
function NodeCard({ node, subtitle }: { node: DuPontTreeNode; subtitle?: string }) {
|
||||
const val = node.unit === "x" ? `${node.value.toFixed(2)}×` : `${node.value.toFixed(2)}%`;
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-bg-primary/40 px-3 py-2">
|
||||
<div className="text-text-muted text-xs">{node.label}</div>
|
||||
<div className={`font-mono text-lg ${trendClass(node.trend)}`}>{val}</div>
|
||||
{node.vs_5y_avg_pct != null && (
|
||||
<div className="text-text-muted text-[10px] mt-0.5">
|
||||
vs 5y avg {node.vs_5y_avg_pct > 0 ? "+" : ""}
|
||||
{node.vs_5y_avg_pct.toFixed(1)}%
|
||||
</div>
|
||||
)}
|
||||
{subtitle && <div className="text-text-muted text-[10px] mt-1">{subtitle}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DuPontTree({ tree }: { tree: DuPontTreePayload | null }) {
|
||||
if (!tree) {
|
||||
return <div className="text-text-muted text-sm">DuPont decomposition not available.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 h-full min-h-0">
|
||||
<NodeCard node={tree.root} subtitle="ROE = NPM × Asset turnover × Equity multiplier" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 flex-1 min-h-0">
|
||||
<NodeCard node={tree.npm} />
|
||||
<NodeCard node={tree.asset_turnover} />
|
||||
<NodeCard node={tree.equity_mult} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import type { FScoreCriterionSeries } from "./types";
|
||||
|
||||
function BinarySparkline({ history }: { history: { year: number; pass_flag: boolean }[] }) {
|
||||
const w = 80;
|
||||
const h = 20;
|
||||
const n = history.length;
|
||||
if (n === 0) {
|
||||
return <div className="w-20 h-5 rounded bg-bg-primary/50 border border-border shrink-0" title="No history" />;
|
||||
}
|
||||
const step = n > 1 ? (w - 8) / (n - 1) : 0;
|
||||
const pts = history.map((pt, i) => {
|
||||
const x = n > 1 ? 4 + i * step : w / 2;
|
||||
const y = pt.pass_flag ? 5 : 15;
|
||||
return { x, y, pt };
|
||||
});
|
||||
const lineD = pts.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(" ");
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} className="w-20 h-5 shrink-0" aria-hidden>
|
||||
<path d={lineD} fill="none" stroke="#6B7280" strokeWidth="1.2" strokeLinecap="round" />
|
||||
{pts.map((p) => (
|
||||
<circle
|
||||
key={p.pt.year}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={3}
|
||||
fill={p.pt.pass_flag ? "#00D4AA" : "#F87171"}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function FScorePanel({
|
||||
total,
|
||||
criteria,
|
||||
}: {
|
||||
total: number;
|
||||
criteria: FScoreCriterionSeries[];
|
||||
}) {
|
||||
const color = total >= 7 ? "text-accent-green" : total >= 4 ? "text-accent-yellow" : "text-accent-red";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-baseline gap-3 mb-3">
|
||||
<span className={`text-4xl font-mono font-bold ${color}`}>{total}</span>
|
||||
<span className="text-text-muted text-sm font-mono">/ 9</span>
|
||||
<span className="text-text-muted text-xs">
|
||||
{total >= 7 ? "Strong" : total >= 4 ? "Moderate" : "Weak"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-text-muted text-xs mb-2">Up to 3 fiscal years per criterion (Piotroski-style).</p>
|
||||
<div className="space-y-1.5 overflow-y-auto flex-1 pr-1">
|
||||
{criteria.map((c) => (
|
||||
<div key={c.key} className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="text-text-muted truncate flex-1 min-w-0" title={c.label}>
|
||||
{c.label}
|
||||
</span>
|
||||
<BinarySparkline history={c.history} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { FScorePanel } from "./FScorePanel";
|
||||
import { DuPontTree } from "./DuPontTree";
|
||||
import { SankeyWidget } from "./SankeyWidget";
|
||||
import { WaterfallWidget } from "./WaterfallWidget";
|
||||
import { AnomalyChips } from "./AnomalyChips";
|
||||
import type { ResearchDashboardPayload } from "./types";
|
||||
|
||||
function Panel({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-lg overflow-hidden flex flex-col min-h-[280px] h-full shadow-sm">
|
||||
<div className="px-3 py-2 border-b border-border text-xs text-text-muted font-semibold tracking-wide uppercase select-none">
|
||||
{title}
|
||||
</div>
|
||||
<div className="p-3 flex-1 min-h-0 overflow-auto">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS grid layout (no react-grid-layout) — avoids SSR/hydration issues with WidthProvider.
|
||||
*/
|
||||
export function ResearchGridLayout({ dashboard }: { dashboard: ResearchDashboardPayload }) {
|
||||
const { ticker, fscore_total, fscore_criteria, dupont_tree, sankey, waterfall, anomalies } = dashboard;
|
||||
const criteria = fscore_criteria ?? [];
|
||||
const sk = sankey ?? { nodes: [], links: [] };
|
||||
const wf = waterfall ?? [];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-3">
|
||||
<div className="lg:col-span-4 min-h-[300px]">
|
||||
<Panel title="F-Score history">
|
||||
<FScorePanel total={Number(fscore_total) || 0} criteria={criteria} />
|
||||
</Panel>
|
||||
</div>
|
||||
<div className="lg:col-span-8 min-h-[300px]">
|
||||
<Panel title="DuPont tree">
|
||||
<DuPontTree tree={dupont_tree} />
|
||||
</Panel>
|
||||
</div>
|
||||
<div className="lg:col-span-7 min-h-[380px]">
|
||||
<Panel title="Income statement flow">
|
||||
<SankeyWidget nodes={sk.nodes} links={sk.links} />
|
||||
</Panel>
|
||||
</div>
|
||||
<div className="lg:col-span-5 min-h-[380px]">
|
||||
<Panel title="Operating income bridge">
|
||||
<WaterfallWidget steps={wf} />
|
||||
</Panel>
|
||||
</div>
|
||||
<div className="lg:col-span-12 min-h-[200px]">
|
||||
<Panel title={`YoY anomalies — ${ticker}`}>
|
||||
<p className="text-text-muted text-xs mb-3">
|
||||
칩을 누르면 10-K 발췌와 Gemini로 설명합니다. 숫자는 서버에서만 계산됩니다.
|
||||
</p>
|
||||
<AnomalyChips ticker={ticker} anomalies={anomalies ?? []} />
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { ResponsiveSankey } from "@nivo/sankey";
|
||||
import type { SankeyNivoLink, SankeyNivoNode } from "./types";
|
||||
|
||||
const nivoTheme = {
|
||||
background: "transparent",
|
||||
text: { fill: "#9CA3AF", fontSize: 11 },
|
||||
tooltip: {
|
||||
container: {
|
||||
background: "#1A1A26",
|
||||
color: "#E5E7EB",
|
||||
fontSize: 12,
|
||||
border: "1px solid #374151",
|
||||
},
|
||||
},
|
||||
labels: { text: { fill: "#D1D5DB" } },
|
||||
};
|
||||
|
||||
export function SankeyWidget({
|
||||
nodes,
|
||||
links,
|
||||
}: {
|
||||
nodes: SankeyNivoNode[];
|
||||
links: SankeyNivoLink[];
|
||||
}) {
|
||||
const data = {
|
||||
nodes: nodes.map((n) => ({
|
||||
id: n.id,
|
||||
label: n.label || n.id,
|
||||
})),
|
||||
links: links.map((l) => ({
|
||||
source: l.source,
|
||||
target: l.target,
|
||||
value: Math.max(l.value, 0),
|
||||
})),
|
||||
};
|
||||
|
||||
if (!data.nodes.length || !data.links.length) {
|
||||
return <div className="text-text-muted text-sm h-full flex items-center justify-center">No Sankey data.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-[min(420px,55vh)] min-h-[280px]">
|
||||
<ResponsiveSankey
|
||||
data={data}
|
||||
margin={{ top: 12, right: 140, bottom: 12, left: 50 }}
|
||||
align="justify"
|
||||
sort="input"
|
||||
colors={{ scheme: "category10" }}
|
||||
nodeOpacity={1}
|
||||
nodeHoverOthersOpacity={0.35}
|
||||
nodeThickness={18}
|
||||
nodeSpacing={24}
|
||||
nodeBorderWidth={0}
|
||||
linkOpacity={0.5}
|
||||
linkHoverOthersOpacity={0.15}
|
||||
linkContract={3}
|
||||
enableLinkGradient
|
||||
labelPosition="outside"
|
||||
labelOrientation="horizontal"
|
||||
labelPadding={12}
|
||||
labelTextColor={{ from: "color", modifiers: [["darker", 1]] }}
|
||||
theme={nivoTheme}
|
||||
valueFormat={(v) => {
|
||||
const abs = Math.abs(v);
|
||||
if (abs >= 1e9) return `$${(v / 1e9).toFixed(2)}B`;
|
||||
if (abs >= 1e6) return `$${(v / 1e6).toFixed(1)}M`;
|
||||
if (abs >= 1e3) return `$${(v / 1e3).toFixed(0)}K`;
|
||||
return `$${v.toFixed(0)}`;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { ResponsiveBar } from "@nivo/bar";
|
||||
import type { WaterfallStep } from "./types";
|
||||
|
||||
const barTheme = {
|
||||
background: "transparent",
|
||||
text: { fill: "#9CA3AF", fontSize: 11 },
|
||||
axis: {
|
||||
domain: { line: { stroke: "#374151" } },
|
||||
ticks: { line: { stroke: "#374151" }, text: { fill: "#9CA3AF" } },
|
||||
legend: { text: { fill: "#9CA3AF" } },
|
||||
},
|
||||
grid: { line: { stroke: "#2D2D3A" } },
|
||||
tooltip: {
|
||||
container: {
|
||||
background: "#1A1A26",
|
||||
color: "#E5E7EB",
|
||||
fontSize: 12,
|
||||
border: "1px solid #374151",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function WaterfallWidget({ steps }: { steps: WaterfallStep[] }) {
|
||||
if (!steps.length) {
|
||||
return (
|
||||
<div className="text-text-muted text-sm h-full flex items-center justify-center">
|
||||
Operating income bridge not available (need 2+ annual columns).
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const data = steps.map((s) => ({
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
delta: s.value,
|
||||
type: s.step_type,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="w-full h-[min(380px,50vh)] min-h-[240px]">
|
||||
<ResponsiveBar
|
||||
data={data}
|
||||
keys={["delta"]}
|
||||
indexBy="label"
|
||||
layout="horizontal"
|
||||
margin={{ top: 8, right: 28, bottom: 40, left: 160 }}
|
||||
padding={0.35}
|
||||
valueScale={{ type: "linear" }}
|
||||
indexScale={{ type: "band", round: true }}
|
||||
colors={({ data: row }) => {
|
||||
const r = row as { type?: string; delta?: number };
|
||||
if (r.type === "total") return "#6366F1";
|
||||
return (r.delta ?? 0) >= 0 ? "#00D4AA" : "#F87171";
|
||||
}}
|
||||
borderRadius={2}
|
||||
axisTop={null}
|
||||
axisRight={null}
|
||||
axisBottom={{
|
||||
tickSize: 0,
|
||||
tickPadding: 8,
|
||||
legend: "USD (reported units)",
|
||||
legendPosition: "middle",
|
||||
legendOffset: 32,
|
||||
}}
|
||||
axisLeft={{
|
||||
tickSize: 0,
|
||||
tickPadding: 8,
|
||||
}}
|
||||
enableGridX
|
||||
enableGridY={false}
|
||||
labelSkipWidth={12}
|
||||
labelSkipHeight={12}
|
||||
labelTextColor="#E5E7EB"
|
||||
theme={barTheme}
|
||||
tooltip={({ value, indexValue }) => (
|
||||
<div className="px-2 py-1 text-xs">
|
||||
<strong>{String(indexValue)}</strong>
|
||||
<div className="font-mono">{typeof value === "number" ? value.toLocaleString() : value}</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export interface FScoreYearPoint {
|
||||
year: number;
|
||||
pass_flag: boolean;
|
||||
}
|
||||
|
||||
export interface FScoreCriterionSeries {
|
||||
key: string;
|
||||
label: string;
|
||||
history: FScoreYearPoint[];
|
||||
}
|
||||
|
||||
export interface DuPontTreeNode {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
avg_5y?: number | null;
|
||||
vs_5y_avg_pct?: number | null;
|
||||
trend: string;
|
||||
}
|
||||
|
||||
export interface DuPontTreePayload {
|
||||
root: DuPontTreeNode;
|
||||
npm: DuPontTreeNode;
|
||||
asset_turnover: DuPontTreeNode;
|
||||
equity_mult: DuPontTreeNode;
|
||||
}
|
||||
|
||||
export interface SankeyNivoNode {
|
||||
id: string;
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
export interface SankeyNivoLink {
|
||||
source: string;
|
||||
target: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface WaterfallStep {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number;
|
||||
cumulative: number;
|
||||
step_type: string;
|
||||
}
|
||||
|
||||
export interface FinancialAnomalyItem {
|
||||
account_key: string;
|
||||
display_name: string;
|
||||
prior_value?: number | null;
|
||||
current_value?: number | null;
|
||||
change_pct?: number | null;
|
||||
direction: string;
|
||||
}
|
||||
|
||||
export interface ResearchDashboardPayload {
|
||||
ticker: string;
|
||||
fscore_total: number;
|
||||
fscore_criteria: FScoreCriterionSeries[];
|
||||
dupont_tree: DuPontTreePayload | null;
|
||||
sankey: {
|
||||
nodes: SankeyNivoNode[];
|
||||
links: SankeyNivoLink[];
|
||||
};
|
||||
waterfall: WaterfallStep[];
|
||||
anomalies: FinancialAnomalyItem[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface AnomalyExplainResult {
|
||||
summary: string;
|
||||
likely_causes: string[];
|
||||
citations: { excerpt: string; context: string }[];
|
||||
confidence: string;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const NAV_ITEMS = [
|
||||
{ href: "/valuation", label: "Valuation", icon: "💰" },
|
||||
{ href: "/technical", label: "Technical", icon: "📈" },
|
||||
{ href: "/markets", label: "Markets", icon: "🌍" },
|
||||
{ href: "/macro", label: "Macro", icon: "🌐" },
|
||||
{ href: "/earnings", label: "Earnings", icon: "📅" },
|
||||
{ href: "/news", label: "News", icon: "📰" },
|
||||
{ href: "/screener", label: "Screener", icon: "🎯" },
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error("[ATLAS] route error:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="min-h-[50vh] flex flex-col items-center justify-center p-8 bg-bg-primary text-text-primary">
|
||||
<p className="text-accent-red font-mono text-sm mb-2">화면을 그리는 중 오류가 났습니다.</p>
|
||||
<p className="text-text-muted text-xs font-mono text-center max-w-md mb-6 break-words">
|
||||
{error.message || "Unknown error"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reset()}
|
||||
className="px-4 py-2 rounded-lg bg-accent-green text-bg-primary font-mono text-sm hover:opacity-90"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
<p className="text-text-muted text-xs mt-8 text-center max-w-lg">
|
||||
흰 화면만 보일 때는 브라우저 개발자 도구(F12) → Console 탭의 빨간 에러 메시지를 확인하거나, 터미널에서{" "}
|
||||
<code className="text-accent-blue">rm -rf .next && npm run dev</code> 로 캐시를 지우고 다시 실행해 보세요.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,148 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
FilingsViewer,
|
||||
type FilingsViewerHandle,
|
||||
type FilingSectionTab,
|
||||
} from "../components/filings/FilingsViewer";
|
||||
import { inferFilingJurisdiction, type FilingJurisdiction } from "../lib/filing-jurisdiction";
|
||||
import { useTicker } from "../lib/use-ticker";
|
||||
|
||||
const SECTIONS = [
|
||||
{ key: "item1a", label: "Item 1A: Risk Factors", short: "Risk Factors" },
|
||||
{ key: "item7", label: "Item 7: MD&A", short: "MD&A" },
|
||||
{ key: "item8", label: "Item 8: Financial Statements", short: "Financials" },
|
||||
{ key: "item3", label: "Item 3: Legal Proceedings", short: "Legal" },
|
||||
{ key: "item9a", label: "Item 9A: Controls & Procedures", short: "Controls" },
|
||||
const SECTIONS_SEC: FilingSectionTab[] = [
|
||||
{ key: "item1a", label: "Item 1A: Risk Factors", short: "Risk Factors", anchorId: "sec-item-1a" },
|
||||
{ key: "item3", label: "Item 3: Legal Proceedings", short: "Legal", anchorId: "sec-item-3" },
|
||||
{ key: "item7", label: "Item 7: MD&A", short: "MD&A", anchorId: "sec-item-7" },
|
||||
{ key: "item8", label: "Item 8: Financial Statements", short: "Financials", anchorId: "sec-item-8" },
|
||||
{ key: "item9a", label: "Item 9A: Controls & Procedures", short: "Controls", anchorId: "sec-item-9a" },
|
||||
];
|
||||
|
||||
const SECTIONS_DART: FilingSectionTab[] = [
|
||||
{ key: "item1a", label: "투자위험 (II)", short: "투자위험", anchorId: "dart-item-1a" },
|
||||
{ key: "item3", label: "소송 등", short: "소송", anchorId: "dart-item-3" },
|
||||
{ key: "item7", label: "사업의 내용 / MD&A", short: "사업·MD&A", anchorId: "dart-item-7" },
|
||||
{ key: "item8", label: "재무에 관한 사항", short: "재무", anchorId: "dart-item-8" },
|
||||
{ key: "item9a", label: "내부통제", short: "내부통제", anchorId: "dart-item-9a" },
|
||||
];
|
||||
|
||||
const SECTIONS_EDINET: FilingSectionTab[] = [
|
||||
{ key: "item1a", label: "リスク情報", short: "リスク", anchorId: "edinet-item-1a" },
|
||||
{ key: "item3", label: "訴訟 (該当時)", short: "訴訟", anchorId: "edinet-item-3" },
|
||||
{ key: "item7", label: "事業の状況 / MD&A", short: "MD&A", anchorId: "edinet-item-7" },
|
||||
{ key: "item8", label: "財務諸表", short: "財務", anchorId: "edinet-item-8" },
|
||||
{ key: "item9a", label: "内部統制", short: "内部統制", anchorId: "edinet-item-9a" },
|
||||
];
|
||||
|
||||
function sectionsForJurisdiction(j: FilingJurisdiction): FilingSectionTab[] {
|
||||
if (j === "DART") return SECTIONS_DART;
|
||||
if (j === "EDINET") return SECTIONS_EDINET;
|
||||
return SECTIONS_SEC;
|
||||
}
|
||||
|
||||
function mapApiSource(s: string | undefined): FilingJurisdiction {
|
||||
if (s === "dart") return "DART";
|
||||
if (s === "edinet") return "EDINET";
|
||||
return "SEC";
|
||||
}
|
||||
|
||||
export default function FilingsPage() {
|
||||
const { ticker } = useTicker();
|
||||
const viewerRef = useRef<FilingsViewerHandle | null>(null);
|
||||
const [activeSection, setActiveSection] = useState("item7");
|
||||
const [sections, setSections] = useState<Record<string, string>>({});
|
||||
const [htmlDoc, setHtmlDoc] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [email, setEmail] = useState("kimseonpil23@gmail.com");
|
||||
const [email, setEmail] = useState("");
|
||||
const [aiSummary, setAiSummary] = useState<string>("");
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [htmlVersion, setHtmlVersion] = useState(0);
|
||||
const [filingSource, setFilingSource] = useState<FilingJurisdiction | null>(null);
|
||||
const [linkMap, setLinkMap] = useState<Record<string, string> | null>(null);
|
||||
const [infoMessage, setInfoMessage] = useState<string>("");
|
||||
|
||||
const previewJ = inferFilingJurisdiction(ticker);
|
||||
const activeJurisdiction = filingSource ?? previewJ;
|
||||
const sectionTabs = useMemo(() => sectionsForJurisdiction(activeJurisdiction), [activeJurisdiction]);
|
||||
|
||||
async function loadFiling() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setSections({});
|
||||
setHtmlDoc("");
|
||||
setAiSummary("");
|
||||
setLinkMap(null);
|
||||
setInfoMessage("");
|
||||
const j = inferFilingJurisdiction(ticker);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/edgar/sections/${ticker}?email=${encodeURIComponent(email)}`);
|
||||
if (j === "SEC") {
|
||||
const qs = new URLSearchParams({
|
||||
email,
|
||||
include_html: "true",
|
||||
});
|
||||
const res = await fetch(`/api/edgar/sections/${encodeURIComponent(ticker)}?${qs.toString()}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setFilingSource(mapApiSource(data.source));
|
||||
setSections({
|
||||
item1a: data.item1a || "",
|
||||
item3: data.item3 || "",
|
||||
item7: data.item7 || "",
|
||||
item8: data.item8 || "",
|
||||
item9a: data.item9a || "",
|
||||
});
|
||||
setHtmlDoc(typeof data.html === "string" ? data.html : "");
|
||||
setActiveSection("item7");
|
||||
setHtmlVersion((v) => v + 1);
|
||||
setLoaded(true);
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
setError(err.detail || "Failed to load SEC filing. Try a different ticker or check your connection.");
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (j === "DART") {
|
||||
const res = await fetch(
|
||||
`/api/dart/sections/${encodeURIComponent(ticker)}?include_html=true`,
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setFilingSource(mapApiSource(data.source));
|
||||
if (data.configured === false) {
|
||||
setInfoMessage(data.message || "DART_API_KEY가 설정되지 않았습니다.");
|
||||
setLoaded(false);
|
||||
} else {
|
||||
setSections({
|
||||
item1a: data.item1a || "",
|
||||
item3: data.item3 || "",
|
||||
item7: data.item7 || "",
|
||||
item8: data.item8 || "",
|
||||
item9a: data.item9a || "",
|
||||
});
|
||||
setHtmlDoc(typeof data.html === "string" ? data.html : "");
|
||||
setActiveSection("item7");
|
||||
setHtmlVersion((v) => v + 1);
|
||||
setLoaded(true);
|
||||
}
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
setError(err.detail || "DART 공시를 불러오지 못했습니다.");
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// EDINET
|
||||
const res = await fetch(
|
||||
`/api/edinet/sections/${encodeURIComponent(ticker)}?include_html=true`,
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setFilingSource(mapApiSource(data.source));
|
||||
setSections({
|
||||
item1a: data.item1a || "",
|
||||
item3: data.item3 || "",
|
||||
@@ -37,10 +150,17 @@ export default function FilingsPage() {
|
||||
item8: data.item8 || "",
|
||||
item9a: data.item9a || "",
|
||||
});
|
||||
setHtmlDoc(typeof data.html === "string" ? data.html : "");
|
||||
if (data.links && typeof data.links === "object") {
|
||||
setLinkMap(data.links as Record<string, string>);
|
||||
}
|
||||
if (data.message) setInfoMessage(data.message);
|
||||
setActiveSection("item7");
|
||||
setHtmlVersion((v) => v + 1);
|
||||
setLoaded(true);
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
setError(err.detail || "Failed to load SEC filing. Try a different ticker or check your connection.");
|
||||
setError(err.detail || "EDINET 데이터를 불러오지 못했습니다.");
|
||||
}
|
||||
} catch {
|
||||
setError("Connection error. Make sure the backend server is running.");
|
||||
@@ -58,13 +178,13 @@ export default function FilingsPage() {
|
||||
}
|
||||
setAiLoading(true);
|
||||
try {
|
||||
const sectionLabel = SECTIONS.find((s) => s.key === activeSection)?.label || activeSection;
|
||||
const sectionLabel = sectionTabs.find((s) => s.key === activeSection)?.label || activeSection;
|
||||
const res = await fetch("/api/analysis/mda", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ticker,
|
||||
question: `Summarize and analyze this 10-K ${sectionLabel} section. Highlight key risks, trends, and important disclosures:\n\n${content.slice(0, 8000)}`,
|
||||
question: `Summarize and analyze this filing section (${sectionLabel}). Highlight key risks, trends, and important disclosures:\n\n${content.slice(0, 8000)}`,
|
||||
api_key: apiKey,
|
||||
}),
|
||||
});
|
||||
@@ -80,35 +200,82 @@ export default function FilingsPage() {
|
||||
|
||||
const currentContent = sections[activeSection] || "";
|
||||
const wordCount = currentContent ? currentContent.split(/\s+/).length : 0;
|
||||
const hasHtml = htmlDoc.length > 0;
|
||||
|
||||
const item7Anchor = sectionTabs.find((s) => s.key === "item7")?.anchorId ?? "sec-item-7";
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasHtml || htmlVersion === 0) return;
|
||||
const t = window.setTimeout(() => viewerRef.current?.scrollToAnchor(item7Anchor), 250);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [hasHtml, htmlVersion, ticker, item7Anchor]);
|
||||
|
||||
const intro = useMemo(() => {
|
||||
if (previewJ === "DART") {
|
||||
return {
|
||||
title: "DART 사업보고서",
|
||||
body: "한국 상장사 최신 사업보고서(연간)를 Open DART에서 받아 옵니다. 티커는 005930.KS 형식이어야 합니다. DART_API_KEY가 .env에 필요합니다.",
|
||||
};
|
||||
}
|
||||
if (previewJ === "EDINET") {
|
||||
return {
|
||||
title: "EDINET 有価証券報告書",
|
||||
body: "東京 (.T) 銘柄の有価証券報告書。APIキー (EDINET_SUBSCRIPTION_KEY) がある場合はZIPから本文を取得します。ない場合は公式リンクのみ表示します。",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "10-K Annual Report (SEC)",
|
||||
body: "Downloads the latest 10-K from SEC EDGAR. The filing is shown with original HTML tables and emphasis, restyled for the terminal dark theme. Section tabs scroll to Item 1A, MD&A, and more.",
|
||||
};
|
||||
}, [previewJ]);
|
||||
|
||||
const pageTitle =
|
||||
previewJ === "DART"
|
||||
? "DART 공시"
|
||||
: previewJ === "EDINET"
|
||||
? "EDINET Filings"
|
||||
: "SEC Filings";
|
||||
|
||||
const needsEmail = previewJ === "SEC";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">
|
||||
<span className="text-accent-green">{ticker}</span> SEC Filings
|
||||
<span className="text-accent-green">{ticker}</span> {pageTitle}
|
||||
</h1>
|
||||
|
||||
{/* Load Section */}
|
||||
{!loaded && (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5 mb-6">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">10-K Annual Report</h3>
|
||||
<p className="text-text-muted text-sm mb-4">
|
||||
Downloads the latest 10-K filing from SEC EDGAR, parses and extracts individual sections for analysis.
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="SEC EDGAR email (required)"
|
||||
className="bg-bg-primary border border-border rounded-md px-3 py-2 text-text-primary outline-none text-sm w-72 focus:border-accent-green/50"
|
||||
/>
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">{intro.title}</h3>
|
||||
<p className="text-text-muted text-sm mb-4">{intro.body}</p>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{needsEmail && (
|
||||
<input
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="SEC EDGAR email (required)"
|
||||
className="bg-bg-primary border border-border rounded-md px-3 py-2 text-text-primary outline-none text-sm w-72 focus:border-accent-green/50"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={loadFiling}
|
||||
disabled={loading || !email}
|
||||
disabled={loading || (needsEmail && !email)}
|
||||
className="bg-accent-green text-bg-primary px-5 py-2 rounded-md text-sm font-semibold hover:opacity-90 disabled:opacity-50 transition-opacity"
|
||||
>
|
||||
{loading ? "Downloading & Parsing..." : "Load 10-K Filing"}
|
||||
{loading
|
||||
? "Loading..."
|
||||
: previewJ === "SEC"
|
||||
? "Load 10-K Filing"
|
||||
: previewJ === "DART"
|
||||
? "사업보고서 불러오기"
|
||||
: "Load EDINET filing"}
|
||||
</button>
|
||||
</div>
|
||||
{infoMessage && (
|
||||
<div className="mt-3 bg-accent-yellow/10 border border-accent-yellow/30 rounded-md px-4 py-2.5 text-accent-yellow text-sm">
|
||||
{infoMessage}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mt-3 bg-accent-red/10 border border-accent-red/30 rounded-md px-4 py-2.5 text-accent-red text-sm">
|
||||
{error}
|
||||
@@ -116,29 +283,56 @@ export default function FilingsPage() {
|
||||
)}
|
||||
{loading && (
|
||||
<div className="mt-3 text-text-muted text-sm animate-pulse">
|
||||
Downloading from SEC EDGAR... This may take 10-30 seconds for first download.
|
||||
{previewJ === "SEC"
|
||||
? "Downloading from SEC EDGAR... This may take 10-30 seconds for first download."
|
||||
: "공시 원본을 가져오는 중입니다..."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loaded Content */}
|
||||
{loaded && (
|
||||
<>
|
||||
{/* Section Tabs */}
|
||||
<div className="flex gap-1 mb-4 bg-bg-card border border-border rounded-lg p-1">
|
||||
{SECTIONS.map((s) => {
|
||||
{linkMap && Object.keys(linkMap).length > 0 && (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4 mb-4 text-sm text-text-secondary">
|
||||
{infoMessage && <p className="mb-2 text-text-muted">{infoMessage}</p>}
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{Object.entries(linkMap).map(([k, v]) => (
|
||||
<li key={k}>
|
||||
<a
|
||||
href={v}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent-blue hover:underline"
|
||||
>
|
||||
{k}: {v}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-1 mb-3 bg-bg-card border border-border rounded-lg p-1 flex-wrap">
|
||||
{sectionTabs.map((s) => {
|
||||
const hasContent = !!sections[s.key];
|
||||
return (
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => { setActiveSection(s.key); setAiSummary(""); }}
|
||||
className={`flex-1 px-3 py-2 rounded-md text-xs font-mono transition-all ${
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveSection(s.key);
|
||||
setAiSummary("");
|
||||
if (hasHtml) {
|
||||
viewerRef.current?.scrollToAnchor(s.anchorId);
|
||||
}
|
||||
}}
|
||||
className={`flex-1 min-w-[88px] px-3 py-2 rounded-md text-xs font-mono transition-all ${
|
||||
activeSection === s.key
|
||||
? "bg-accent-green text-bg-primary font-semibold"
|
||||
: hasContent
|
||||
? "text-text-secondary hover:text-text-primary"
|
||||
: "text-text-muted/50"
|
||||
? "text-text-secondary hover:text-text-primary"
|
||||
: "text-text-muted/50"
|
||||
}`}
|
||||
>
|
||||
{s.short}
|
||||
@@ -147,19 +341,19 @@ export default function FilingsPage() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Section Header Bar */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-text-primary text-sm font-semibold">
|
||||
{SECTIONS.find((s) => s.key === activeSection)?.label}
|
||||
{sectionTabs.find((s) => s.key === activeSection)?.label}
|
||||
</h2>
|
||||
{currentContent && (
|
||||
<span className="text-text-muted text-xs font-mono">{wordCount.toLocaleString()} words</span>
|
||||
<span className="text-text-muted text-xs font-mono">{wordCount.toLocaleString()} words (plain cache)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{currentContent && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={runAiSummary}
|
||||
disabled={aiLoading}
|
||||
className="bg-accent-blue text-white px-4 py-1.5 rounded-md text-xs font-semibold hover:opacity-90 disabled:opacity-50 transition-opacity"
|
||||
@@ -168,6 +362,7 @@ export default function FilingsPage() {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadFiling}
|
||||
disabled={loading}
|
||||
className="bg-bg-card border border-border text-text-secondary px-3 py-1.5 rounded-md text-xs hover:text-text-primary transition-colors"
|
||||
@@ -177,58 +372,51 @@ export default function FilingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Summary */}
|
||||
{!hasHtml && previewJ === "SEC" && (
|
||||
<p className="text-text-muted text-sm mb-3">
|
||||
<strong className="text-text-secondary">서식 HTML 스냅샷</strong>이 아직 없습니다(예: 예전에 텍스트만 캐시된 경우).
|
||||
아래에 SEC에서 추출한 <strong className="text-text-secondary">평문</strong>이 그대로 표시됩니다.
|
||||
표·강조가 있는 원문 형태를 보려면 <strong className="text-text-secondary">Reload</strong>로 다시 받아
|
||||
HTML을 만드세요. (야후 iframe 차단과는 무관합니다.)
|
||||
</p>
|
||||
)}
|
||||
{!hasHtml && previewJ !== "SEC" && currentContent && (
|
||||
<p className="text-text-muted text-sm mb-3">
|
||||
HTML 조각이 없을 때는 아래 <strong className="text-text-secondary">평문</strong>으로 동일 내용을 표시합니다.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{aiSummary && (
|
||||
<div className="bg-bg-card border border-accent-green/30 rounded-lg p-5 mb-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-accent-green text-sm">🤖</span>
|
||||
<h3 className="text-accent-green text-sm font-semibold">AI Analysis</h3>
|
||||
<span className="text-accent-green text-sm">AI</span>
|
||||
<h3 className="text-accent-green text-sm font-semibold">Analysis</h3>
|
||||
</div>
|
||||
<div className="text-text-primary text-sm leading-relaxed whitespace-pre-wrap">{aiSummary}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filing Content - Inline Display */}
|
||||
{currentContent ? (
|
||||
<div className="bg-bg-card border border-border rounded-lg overflow-hidden">
|
||||
<div
|
||||
className="p-6 overflow-y-auto text-text-primary text-sm leading-[1.8] font-sans"
|
||||
style={{ maxHeight: "calc(100vh - 340px)" }}
|
||||
>
|
||||
{currentContent.split("\n").map((line, i) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return <div key={i} className="h-3" />;
|
||||
{hasHtml && (
|
||||
<FilingsViewer
|
||||
ref={viewerRef}
|
||||
html={htmlDoc}
|
||||
sections={sectionTabs}
|
||||
activeSection={activeSection}
|
||||
onActiveSectionChange={setActiveSection}
|
||||
/>
|
||||
)}
|
||||
|
||||
// Detect headers (all-caps lines or lines starting with "Item")
|
||||
const isHeader = /^(Item\s+\d|ITEM\s+\d)/i.test(trimmed) ||
|
||||
(trimmed.length < 80 && trimmed === trimmed.toUpperCase() && /[A-Z]/.test(trimmed));
|
||||
const isBullet = /^[•\-\*●]\s/.test(trimmed) || /^\d+\.\s/.test(trimmed);
|
||||
|
||||
if (isHeader) {
|
||||
return (
|
||||
<h3 key={i} className="text-accent-green font-semibold text-base mt-5 mb-2 border-b border-border/30 pb-1">
|
||||
{trimmed}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
if (isBullet) {
|
||||
return (
|
||||
<div key={i} className="pl-4 py-0.5 text-text-secondary">
|
||||
{trimmed}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p key={i} className="mb-1.5 text-text-primary/90">
|
||||
{trimmed}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
{!hasHtml && currentContent && (
|
||||
<div className="border border-border rounded-lg bg-bg-card overflow-hidden flex flex-col max-h-[min(72vh,calc(100vh-200px))]">
|
||||
<div className="px-4 py-2 border-b border-border bg-bg-primary/50 text-xs text-text-muted shrink-0">
|
||||
Plain text (cached) — same source as AI Summary; formatted HTML viewer is optional.
|
||||
</div>
|
||||
<div
|
||||
className="overflow-y-auto flex-1 min-h-0 p-4 md:p-6 pb-10 text-sm text-text-primary leading-relaxed whitespace-pre-wrap break-words font-mono"
|
||||
style={{ WebkitOverflowScrolling: "touch" }}
|
||||
>
|
||||
{currentContent}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-8 text-center text-text-muted">
|
||||
No content available for this section.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Root error UI when the root layout fails. Must define html/body (Next.js requirement).
|
||||
*/
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body style={{ margin: 0, background: "#0a0a0f", color: "#e5e7eb", fontFamily: "system-ui, sans-serif", padding: 24 }}>
|
||||
<h2 style={{ color: "#ff4757" }}>ATLAS Terminal — fatal error</h2>
|
||||
<p style={{ fontSize: 14, marginTop: 8 }}>{error.message || "Unknown error"}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reset()}
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: "8px 16px",
|
||||
background: "#00d4aa",
|
||||
color: "#0a0a0f",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,53 @@ body {
|
||||
font-family: "Inter", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
--text-primary: #f3f4f6;
|
||||
--text-secondary: #9ca3af;
|
||||
--border-color: #2a2a3a;
|
||||
--bg-card: #1a1a26;
|
||||
}
|
||||
|
||||
.sec-viewer-container {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.65;
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sec-viewer-container [id^="sec-item-"] {
|
||||
scroll-margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.sec-viewer-container,
|
||||
.sec-viewer-container * {
|
||||
background-color: transparent !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.sec-viewer-container a {
|
||||
color: #4da6ff !important;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.sec-viewer-container table {
|
||||
border-collapse: collapse;
|
||||
width: max-content;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.sec-viewer-container th,
|
||||
.sec-viewer-container td {
|
||||
border: 1px solid var(--border-color) !important;
|
||||
padding: 0.35rem 0.5rem !important;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.sec-viewer-container img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { Sidebar } from "./components/sidebar";
|
||||
import { TickerBar } from "./components/ticker-bar";
|
||||
import { ChatPanel } from "./components/chat-panel";
|
||||
import { AppShell } from "./components/app-shell";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "ATLAS Terminal",
|
||||
@@ -19,15 +17,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<TickerBar />
|
||||
<div className="flex pt-[52px] min-h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex-1 ml-[260px] mr-[380px] p-7 bg-bg-primary min-h-[calc(100vh-52px)] transition-all duration-200">
|
||||
{children}
|
||||
</main>
|
||||
<ChatPanel />
|
||||
</div>
|
||||
<body className="min-h-screen antialiased" suppressHydrationWarning>
|
||||
<AppShell>{children}</AppShell>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Match server `infer_filing_jurisdiction` for Filings page routing. */
|
||||
export type FilingJurisdiction = "SEC" | "DART" | "EDINET";
|
||||
|
||||
export function inferFilingJurisdiction(ticker: string): FilingJurisdiction {
|
||||
const t = (ticker || "").trim().toUpperCase();
|
||||
if (t.endsWith(".KS") || t.endsWith(".KQ")) return "DART";
|
||||
if (t.endsWith(".T")) return "EDINET";
|
||||
return "SEC";
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
const TICKER_ALIASES: Record<string, string> = {
|
||||
// Equities (natural language -> Yahoo suffix)
|
||||
samsung: "005930.KS",
|
||||
|
||||
// Commodities (natural language -> futures ticker)
|
||||
gold: "GC=F",
|
||||
silver: "SI=F",
|
||||
|
||||
@@ -6,16 +6,17 @@ const DEFAULT_TICKER = "AAPL";
|
||||
const STORAGE_KEY = "atlas_active_ticker";
|
||||
const EVENT_NAME = "atlas-ticker-change";
|
||||
|
||||
function getInitialTicker(): string {
|
||||
if (typeof window === "undefined") return DEFAULT_TICKER;
|
||||
const saved = localStorage.getItem(STORAGE_KEY) || DEFAULT_TICKER;
|
||||
return normalizeTickerInput(saved);
|
||||
}
|
||||
|
||||
export function useTicker() {
|
||||
const [ticker, setTickerState] = useState(getInitialTicker);
|
||||
// Must match server render: never read localStorage in useState initializer — hydration mismatch → white screen.
|
||||
const [ticker, setTickerState] = useState(DEFAULT_TICKER);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const n = normalizeTickerInput(saved);
|
||||
if (n) setTickerState(n);
|
||||
}
|
||||
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (detail) setTickerState(detail);
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { MacroCycleHeatmap, type MacroSnapshot } from "../components/markets/MacroCycleHeatmap";
|
||||
import { OECDCycleChart, type OECDData } from "../components/markets/OECDCycleChart";
|
||||
import { KoreaMonitor } from "../components/markets/KoreaMonitor";
|
||||
import { EconomicCalendar } from "../components/markets/EconomicCalendar";
|
||||
import { GlobalMacroQuadrantChart, type QuadrantPoint } from "../components/macro/GlobalMacroQuadrantChart";
|
||||
import { YieldFxDualAxisChart, type YieldFxRow } from "../components/macro/YieldFxDualAxisChart";
|
||||
import { SmartMoneyPanel } from "../components/macro/SmartMoneyPanel";
|
||||
|
||||
const FRED_PRESETS = [
|
||||
{ id: "UNRATE", label: "US Unemployment %" },
|
||||
{ id: "CPIAUCSL", label: "US CPI (All Urban)" },
|
||||
{ id: "DFF", label: "Fed Funds Effective" },
|
||||
{ id: "T10Y2Y", label: "10Y-2Y Treasury spread" },
|
||||
];
|
||||
|
||||
interface FredPoint {
|
||||
date: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
type MacroTab = "fred" | "cycle" | "oecd" | "korea" | "calendar";
|
||||
|
||||
export default function MacroPage() {
|
||||
const [series, setSeries] = useState("UNRATE");
|
||||
const [rows, setRows] = useState<FredPoint[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const [tab, setTab] = useState<MacroTab>("fred");
|
||||
const [snap, setSnap] = useState<MacroSnapshot | null>(null);
|
||||
const [snapErr, setSnapErr] = useState<string | null>(null);
|
||||
const [oecd, setOecd] = useState<OECDData | null>(null);
|
||||
|
||||
const [classicOpen, setClassicOpen] = useState(false);
|
||||
|
||||
const [quadPoints, setQuadPoints] = useState<QuadrantPoint[]>([]);
|
||||
const [quadErr, setQuadErr] = useState<string | null>(null);
|
||||
|
||||
const [yieldPair, setYieldPair] = useState<"usdjpy" | "eurusd" | "usdkrw">("usdjpy");
|
||||
const [yieldRows, setYieldRows] = useState<YieldFxRow[]>([]);
|
||||
const [yieldErr, setYieldErr] = useState<string | null>(null);
|
||||
|
||||
const [roroZ, setRoroZ] = useState<number | null>(null);
|
||||
const [roroLabel, setRoroLabel] = useState<string | null>(null);
|
||||
const [copperGold, setCopperGold] = useState<
|
||||
{ date: string; ratio: number; ratio_ma20?: number }[]
|
||||
>([]);
|
||||
const [smartErr, setSmartErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
fetch(`/api/macro/fred/${encodeURIComponent(series)}`)
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
|
||||
.then((j) => {
|
||||
setRows(Array.isArray(j.data) ? j.data : []);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setErr("Failed to load FRED series.");
|
||||
setLoading(false);
|
||||
});
|
||||
}, [series]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/macro/snapshot")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((j) => {
|
||||
if (!j || typeof j !== "object") {
|
||||
setSnap(null);
|
||||
return;
|
||||
}
|
||||
if (j.error) setSnapErr(String(j.error));
|
||||
else setSnapErr(null);
|
||||
setSnap({
|
||||
updated_at: j.updated_at ?? null,
|
||||
cycle_score: typeof j.cycle_score === "number" ? j.cycle_score : 0,
|
||||
regime: String(j.regime ?? "—"),
|
||||
cycle_heatmap: Array.isArray(j.cycle_heatmap) ? j.cycle_heatmap : [],
|
||||
country_heatmap: Array.isArray(j.country_heatmap) ? j.country_heatmap : [],
|
||||
asset_valuation: Array.isArray(j.asset_valuation) ? j.asset_valuation : [],
|
||||
});
|
||||
})
|
||||
.catch(() => setSnap(null));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/macro/oecd/cli")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((j) => setOecd(j && typeof j === "object" ? j : null))
|
||||
.catch(() => setOecd(null));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setQuadErr(null);
|
||||
fetch("/api/macro/quadrant")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((j) => {
|
||||
if (!j || typeof j !== "object") {
|
||||
setQuadPoints([]);
|
||||
return;
|
||||
}
|
||||
if (j.error) setQuadErr(String(j.error));
|
||||
setQuadPoints(Array.isArray(j.points) ? j.points : []);
|
||||
})
|
||||
.catch(() => setQuadPoints([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setYieldErr(null);
|
||||
fetch(`/api/macro/yield-fx?pair=${encodeURIComponent(yieldPair)}`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((j) => {
|
||||
if (!j || typeof j !== "object") {
|
||||
setYieldRows([]);
|
||||
return;
|
||||
}
|
||||
if (j.error) setYieldErr(String(j.error));
|
||||
setYieldRows(Array.isArray(j.series) ? j.series : []);
|
||||
})
|
||||
.catch(() => setYieldRows([]));
|
||||
}, [yieldPair]);
|
||||
|
||||
useEffect(() => {
|
||||
setSmartErr(null);
|
||||
fetch("/api/macro/smart-money")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((j) => {
|
||||
if (!j || typeof j !== "object") {
|
||||
setRoroZ(null);
|
||||
setRoroLabel(null);
|
||||
setCopperGold([]);
|
||||
return;
|
||||
}
|
||||
if (j.error) setSmartErr(String(j.error));
|
||||
setRoroZ(typeof j.roro_z === "number" ? j.roro_z : null);
|
||||
setRoroLabel(j.roro_label != null ? String(j.roro_label) : null);
|
||||
setCopperGold(Array.isArray(j.copper_gold) ? j.copper_gold : []);
|
||||
})
|
||||
.catch(() => {
|
||||
setRoroZ(null);
|
||||
setRoroLabel(null);
|
||||
setCopperGold([]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const tabs: { key: MacroTab; label: string }[] = [
|
||||
{ key: "fred", label: "FRED" },
|
||||
{ key: "cycle", label: "Cycle & valuation" },
|
||||
{ key: "oecd", label: "OECD CLI" },
|
||||
{ key: "korea", label: "Korea" },
|
||||
{ key: "calendar", label: "Calendar" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-7 max-w-7xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-2 text-text-primary">Global Macro & Smart Money</h1>
|
||||
<p className="text-text-muted text-sm">
|
||||
Growth vs inflation quadrant (FRED / OECD), US–peer 10Y spreads vs FX (FRED + Yahoo), and copper/gold with a
|
||||
VIX + bond-vol RORO gauge. Quantitative series are computed in Python only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-bg-card border border-border rounded-lg overflow-hidden p-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4">
|
||||
<div className="lg:col-span-12 bg-bg-secondary/30 border border-border/60 rounded-lg p-4">
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-1">Global macro quadrant</h2>
|
||||
<p className="text-text-muted text-xs mb-3">3M momentum Z-scores (growth vs inflation).</p>
|
||||
{quadErr && (
|
||||
<div className="text-accent-yellow text-xs font-mono mb-2">Warning: {quadErr}</div>
|
||||
)}
|
||||
<GlobalMacroQuadrantChart points={quadPoints} />
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-6 bg-bg-secondary/30 border border-border/60 rounded-lg p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
|
||||
<h2 className="text-sm font-semibold text-text-primary">Yield spread vs FX</h2>
|
||||
<div className="flex gap-1">
|
||||
{(["usdjpy", "eurusd", "usdkrw"] as const).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => setYieldPair(p)}
|
||||
className={`px-2 py-1 rounded text-xs font-mono border ${
|
||||
yieldPair === p
|
||||
? "bg-accent-green/15 border-accent-green text-accent-green"
|
||||
: "border-border text-text-secondary hover:bg-bg-hover"
|
||||
}`}
|
||||
>
|
||||
{p.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{yieldErr && (
|
||||
<div className="text-accent-yellow text-xs font-mono mb-2">Warning: {yieldErr}</div>
|
||||
)}
|
||||
<YieldFxDualAxisChart pair={yieldPair} rows={yieldRows} />
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-6 bg-bg-secondary/30 border border-border/60 rounded-lg p-4">
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-1">Smart money & RORO</h2>
|
||||
<p className="text-text-muted text-xs mb-3">HG/GC ratio and composite risk Z-score.</p>
|
||||
{smartErr && (
|
||||
<div className="text-accent-yellow text-xs font-mono mb-2">Warning: {smartErr}</div>
|
||||
)}
|
||||
<SmartMoneyPanel roroZ={roroZ} roroLabel={roroLabel} copperGold={copperGold} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg bg-bg-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setClassicOpen(!classicOpen)}
|
||||
className="w-full text-left px-4 py-3 text-sm font-medium text-text-primary hover:bg-bg-hover flex justify-between items-center"
|
||||
>
|
||||
<span>Classic macro tools</span>
|
||||
<span className="text-text-muted font-mono text-xs">{classicOpen ? "▼" : "▶"}</span>
|
||||
</button>
|
||||
{classicOpen && (
|
||||
<div className="px-4 pb-6 pt-2 border-t border-border space-y-6">
|
||||
<div className="flex flex-wrap gap-1 bg-bg-secondary rounded-lg p-1 w-fit">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium ${
|
||||
tab === t.key ? "bg-accent-green text-bg-primary" : "text-text-secondary hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "fred" && (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FRED_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setSeries(p.id)}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-mono border ${
|
||||
series === p.id
|
||||
? "bg-accent-green/15 border-accent-green text-accent-green"
|
||||
: "bg-bg-card border-border text-text-secondary hover:bg-bg-hover"
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-accent-green animate-pulse font-mono">Loading data...</div>
|
||||
) : err ? (
|
||||
<div className="text-accent-red border border-accent-red/30 rounded-lg p-4">{err}</div>
|
||||
) : (
|
||||
<div className="bg-bg-secondary border border-border rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2 border-b border-border text-text-muted text-sm font-mono">
|
||||
{series} — {rows.length} observations (recent last)
|
||||
</div>
|
||||
<div className="max-h-[480px] overflow-y-auto">
|
||||
<table className="w-full text-sm font-mono">
|
||||
<thead>
|
||||
<tr className="text-left text-text-muted border-b border-border">
|
||||
<th className="p-3">Date</th>
|
||||
<th className="p-3">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...rows].reverse().slice(0, 60).map((r) => (
|
||||
<tr key={r.date} className="border-b border-border/50 hover:bg-bg-hover">
|
||||
<td className="p-2 text-text-secondary">{r.date}</td>
|
||||
<td className="p-2 text-accent-green">{r.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "cycle" && (
|
||||
<div className="space-y-4">
|
||||
{snapErr && (
|
||||
<div className="text-accent-yellow border border-accent-yellow/30 rounded-lg p-3 text-sm">
|
||||
Snapshot warning: {snapErr}
|
||||
</div>
|
||||
)}
|
||||
<MacroCycleHeatmap data={snap} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "oecd" && <OECDCycleChart data={oecd} />}
|
||||
|
||||
{tab === "korea" && <KoreaMonitor />}
|
||||
|
||||
{tab === "calendar" && <EconomicCalendar />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTicker } from "../lib/use-ticker";
|
||||
|
||||
interface NewsItem {
|
||||
@@ -10,15 +11,87 @@ interface NewsItem {
|
||||
summary: string;
|
||||
}
|
||||
|
||||
type CategoryFilter = "all" | "earnings" | "fed" | "rates" | "crypto" | "stocks";
|
||||
|
||||
const STOPWORDS = new Set([
|
||||
"THE", "AND", "FOR", "ARE", "BUT", "NOT", "YOU", "ALL", "CAN", "HER", "WAS", "ONE", "OUR", "OUT",
|
||||
"DAY", "GET", "HAS", "HIM", "HIS", "HOW", "ITS", "MAY", "NEW", "NOW", "OLD", "SEE", "WAY", "WHO",
|
||||
"BOY", "DID", "CAR", "CEO", "CFO", "IPO", "LLC", "ETF", "USD", "GDP", "CNN", "BBC", "WSJ", "BAR",
|
||||
"BIG", "SAY", "PUT", "END", "SET", "RUN", "LOT", "LET", "TOO", "TWO", "VIA", "APP", "WEB",
|
||||
]);
|
||||
|
||||
function extractTickers(text: string): string[] {
|
||||
const upper = text.toUpperCase();
|
||||
const re = /\b([A-Z]{2,5})\b/g;
|
||||
const found = new Set<string>();
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(upper)) !== null) {
|
||||
const w = m[1];
|
||||
if (STOPWORDS.has(w)) continue;
|
||||
found.add(w);
|
||||
}
|
||||
return Array.from(found).slice(0, 16);
|
||||
}
|
||||
|
||||
/** Many publishers (Yahoo, Bloomberg, WSJ, …) send X-Frame-Options / CSP so embedded iframes show a blank/broken page. */
|
||||
function isIframeEmbeddingBlocked(url: string): boolean {
|
||||
try {
|
||||
const host = new URL(url).hostname.toLowerCase().replace(/^www\./, "");
|
||||
const suffixes = [
|
||||
"yahoo.com",
|
||||
"yahoo.co.jp",
|
||||
"bloomberg.com",
|
||||
"bloomberg.net",
|
||||
"wsj.com",
|
||||
"reuters.com",
|
||||
"ft.com",
|
||||
"cnbc.com",
|
||||
"marketwatch.com",
|
||||
"seekingalpha.com",
|
||||
"nytimes.com",
|
||||
"washingtonpost.com",
|
||||
"theguardian.com",
|
||||
"bbc.com",
|
||||
"bbc.co.uk",
|
||||
"forbes.com",
|
||||
"investing.com",
|
||||
"apnews.com",
|
||||
"afp.com",
|
||||
];
|
||||
return suffixes.some((s) => host === s || host.endsWith(`.${s}`));
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function matchesCategory(cat: CategoryFilter, title: string, summary: string): boolean {
|
||||
const t = `${title} ${summary}`;
|
||||
if (cat === "all") return true;
|
||||
if (cat === "earnings") return /earnings|EPS|guidance|beat|miss|revenue|quarter|Q[1-4]\s/i.test(t);
|
||||
if (cat === "fed") return /fed|FOMC|Powell|rate cut|rate hike|central bank/i.test(t);
|
||||
if (cat === "rates") return /treasury|yield|bond|10-?year|inflation|CPI|PCE/i.test(t);
|
||||
if (cat === "crypto") return /bitcoin|btc|eth|ethereum|crypto|solana|coinbase/i.test(t);
|
||||
if (cat === "stocks") return /stock|shares|Nasdaq|NYSE|S&P|equity|trading|investor/i.test(t);
|
||||
return true;
|
||||
}
|
||||
|
||||
interface QuoteRow {
|
||||
current_price: number | null;
|
||||
change_pct: number | null;
|
||||
}
|
||||
|
||||
export default function NewsPage() {
|
||||
const { ticker } = useTicker();
|
||||
const [news, setNews] = useState<NewsItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedIdx, setSelectedIdx] = useState<number | null>(null);
|
||||
const [selected, setSelected] = useState<NewsItem | null>(null);
|
||||
const [category, setCategory] = useState<CategoryFilter>("all");
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [mentionQuotes, setMentionQuotes] = useState<Record<string, QuoteRow>>({});
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setSelectedIdx(null);
|
||||
setSelected(null);
|
||||
fetch(`/api/news/${ticker}`)
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((data) => {
|
||||
@@ -28,6 +101,52 @@ export default function NewsPage() {
|
||||
.catch(() => setLoading(false));
|
||||
}, [ticker]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const kw = keyword.trim().toLowerCase();
|
||||
return news.filter((item) => {
|
||||
if (!matchesCategory(category, item.title, item.summary || "")) return false;
|
||||
if (!kw) return true;
|
||||
const blob = `${item.title} ${item.summary || ""}`.toLowerCase();
|
||||
return blob.includes(kw);
|
||||
});
|
||||
}, [news, category, keyword]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) {
|
||||
setMentionQuotes({});
|
||||
return;
|
||||
}
|
||||
const syms = extractTickers(`${selected.title} ${selected.summary || ""}`).filter((s) => s !== ticker.toUpperCase());
|
||||
const take = syms.slice(0, 8);
|
||||
if (take.length === 0) {
|
||||
setMentionQuotes({});
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
Promise.all(
|
||||
take.map((sym) =>
|
||||
fetch(`/api/market/quote/${encodeURIComponent(sym)}`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((j) => ({ sym, j }))
|
||||
)
|
||||
).then((rows) => {
|
||||
if (cancelled) return;
|
||||
const next: Record<string, QuoteRow> = {};
|
||||
for (const { sym, j } of rows) {
|
||||
if (j && typeof j === "object") {
|
||||
next[sym] = {
|
||||
current_price: typeof j.current_price === "number" ? j.current_price : null,
|
||||
change_pct: typeof j.change_pct === "number" ? j.change_pct : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
setMentionQuotes(next);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selected, ticker]);
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -35,7 +154,14 @@ export default function NewsPage() {
|
||||
</div>
|
||||
);
|
||||
|
||||
const selectedItem = selectedIdx !== null ? news[selectedIdx] : null;
|
||||
const cats: { key: CategoryFilter; label: string }[] = [
|
||||
{ key: "all", label: "All" },
|
||||
{ key: "earnings", label: "Earnings" },
|
||||
{ key: "fed", label: "Fed / policy" },
|
||||
{ key: "rates", label: "Rates / inflation" },
|
||||
{ key: "crypto", label: "Crypto" },
|
||||
{ key: "stocks", label: "Equities" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -43,73 +169,123 @@ export default function NewsPage() {
|
||||
<span className="text-accent-green">{ticker}</span> News Feed
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
{cats.map((c) => (
|
||||
<button
|
||||
key={c.key}
|
||||
type="button"
|
||||
onClick={() => setCategory(c.key)}
|
||||
className={`px-3 py-1 rounded-md text-xs font-medium border ${
|
||||
category === c.key
|
||||
? "bg-accent-green/15 border-accent-green text-accent-green"
|
||||
: "bg-bg-card border-border text-text-secondary hover:border-accent-green/40"
|
||||
}`}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
type="search"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="Keyword filter…"
|
||||
className="ml-2 flex-1 min-w-[140px] max-w-xs bg-bg-card border border-border rounded-md px-3 py-1 text-sm text-text-primary placeholder:text-text-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-text-muted text-sm mb-4">
|
||||
{news.length} articles from Finviz & Google News
|
||||
{filtered.length} of {news.length} articles (Finviz, Google News, Yahoo)
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4" style={{ height: "calc(100vh - 200px)" }}>
|
||||
{/* Article List */}
|
||||
<div
|
||||
className={`${
|
||||
selectedItem ? "w-[340px] shrink-0" : "w-full"
|
||||
} overflow-y-auto transition-all duration-200`}
|
||||
className={`${selected ? "w-[340px] shrink-0" : "w-full"} overflow-y-auto transition-all duration-200`}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{news.length > 0 ? (
|
||||
news.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => setSelectedIdx(i)}
|
||||
className={`cursor-pointer rounded-lg p-3 transition-all border ${
|
||||
selectedIdx === i
|
||||
? "bg-accent-green/10 border-accent-green/50"
|
||||
: "bg-bg-card border-border hover:border-accent-green/30"
|
||||
}`}
|
||||
>
|
||||
<h3
|
||||
className={`text-sm font-semibold leading-snug ${
|
||||
selectedIdx === i ? "text-accent-green" : "text-text-primary"
|
||||
{filtered.length > 0 ? (
|
||||
filtered.map((item) => {
|
||||
const mentions = extractTickers(`${item.title} ${item.summary || ""}`).slice(0, 5);
|
||||
return (
|
||||
<div
|
||||
key={item.url}
|
||||
onClick={() => setSelected(item)}
|
||||
className={`cursor-pointer rounded-lg p-3 transition-all border ${
|
||||
selected?.url === item.url
|
||||
? "bg-accent-green/10 border-accent-green/50"
|
||||
: "bg-bg-card border-border hover:border-accent-green/30"
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{item.source && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-accent-blue/10 text-accent-blue rounded font-mono">
|
||||
{item.source}
|
||||
</span>
|
||||
<h3
|
||||
className={`text-sm font-semibold leading-snug ${
|
||||
selected?.url === item.url ? "text-accent-green" : "text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
{mentions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{mentions.map((m) => (
|
||||
<span
|
||||
key={m}
|
||||
className="text-[10px] px-1.5 py-0.5 bg-bg-primary border border-border rounded font-mono text-accent-blue"
|
||||
>
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-text-muted text-[10px] font-mono">
|
||||
{item.published_at}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{item.source && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-accent-blue/10 text-accent-blue rounded font-mono">
|
||||
{item.source}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-text-muted text-[10px] font-mono">{item.published_at}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-text-muted text-center py-12">No news articles found</div>
|
||||
<div className="text-text-muted text-center py-12">No articles match filters</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Article Content - Right Panel */}
|
||||
{selectedItem && (
|
||||
{selected && (
|
||||
<div className="flex-1 flex flex-col bg-bg-card border border-border rounded-lg overflow-hidden min-w-0">
|
||||
{/* Header */}
|
||||
<div className="px-5 py-4 border-b border-border bg-bg-primary/50 shrink-0">
|
||||
<h2 className="text-base font-bold text-text-primary leading-snug mb-2">
|
||||
{selectedItem.title}
|
||||
</h2>
|
||||
<h2 className="text-base font-bold text-text-primary leading-snug mb-2">{selected.title}</h2>
|
||||
{Object.keys(mentionQuotes).length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{Object.entries(mentionQuotes).map(([sym, q]) => (
|
||||
<div
|
||||
key={sym}
|
||||
className="text-[11px] font-mono px-2 py-1 rounded border border-border bg-bg-primary"
|
||||
>
|
||||
<span className="text-accent-green">{sym}</span>
|
||||
{q.change_pct != null && (
|
||||
<span className={q.change_pct >= 0 ? " text-accent-green" : " text-accent-red"}>
|
||||
{" "}
|
||||
{q.change_pct >= 0 ? "+" : ""}
|
||||
{q.change_pct.toFixed(2)}%
|
||||
</span>
|
||||
)}
|
||||
{q.current_price != null && (
|
||||
<span className="text-text-muted"> @ ${q.current_price.toFixed(2)}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
{selectedItem.source && (
|
||||
{selected.source && (
|
||||
<span className="text-xs px-2 py-0.5 bg-accent-blue/10 text-accent-blue rounded font-mono">
|
||||
{selectedItem.source}
|
||||
{selected.source}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-text-muted text-xs font-mono">
|
||||
{selectedItem.published_at}
|
||||
</span>
|
||||
<span className="text-text-muted text-xs font-mono">{selected.published_at}</span>
|
||||
<a
|
||||
href={selectedItem.url}
|
||||
href={selected.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-auto text-xs px-3 py-1 bg-accent-green text-bg-primary rounded-md font-semibold hover:opacity-90 transition-opacity"
|
||||
@@ -117,22 +293,45 @@ export default function NewsPage() {
|
||||
Open Original ↗
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setSelectedIdx(null)}
|
||||
type="button"
|
||||
onClick={() => setSelected(null)}
|
||||
className="text-text-muted hover:text-text-primary transition-colors text-base"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Article Embed */}
|
||||
<div className="flex-1 relative bg-white">
|
||||
<iframe
|
||||
src={selectedItem.url}
|
||||
className="w-full h-full border-0"
|
||||
sandbox="allow-scripts allow-same-origin allow-popups"
|
||||
referrerPolicy="no-referrer"
|
||||
title={selectedItem.title}
|
||||
/>
|
||||
<div className="flex-1 relative min-h-[320px] bg-bg-secondary overflow-auto">
|
||||
{isIframeEmbeddingBlocked(selected.url) ? (
|
||||
<div className="flex flex-col items-center justify-center h-full min-h-[280px] p-8 text-center">
|
||||
<p className="text-text-muted text-sm mb-2 max-w-md">
|
||||
이 출처(Yahoo·Bloomberg 등)는 보안 정책으로 <span className="text-text-primary font-semibold">미리보기 iframe</span>을
|
||||
허용하지 않습니다. 원문은 새 탭에서 열어 주세요.
|
||||
</p>
|
||||
{selected.summary ? (
|
||||
<div className="w-full max-w-2xl mt-4 mb-6 text-left rounded-lg border border-border bg-bg-card p-4">
|
||||
<div className="text-text-muted text-xs font-semibold uppercase tracking-wide mb-2">Summary</div>
|
||||
<p className="text-text-secondary text-sm leading-relaxed whitespace-pre-wrap">{selected.summary}</p>
|
||||
</div>
|
||||
) : null}
|
||||
<a
|
||||
href={selected.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-accent-green text-bg-primary rounded-lg font-semibold text-sm hover:opacity-90"
|
||||
>
|
||||
원문 열기 ↗
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
src={selected.url}
|
||||
className="w-full h-full min-h-[480px] border-0 bg-bg-primary"
|
||||
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
|
||||
referrerPolicy="no-referrer"
|
||||
title={selected.title}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] flex-col items-center justify-center gap-4">
|
||||
<h1 className="text-2xl font-bold text-text-primary">404</h1>
|
||||
<p className="text-text-muted">This page does not exist.</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-md bg-accent-green px-4 py-2 text-sm font-semibold text-bg-primary no-underline hover:opacity-90"
|
||||
>
|
||||
Back to Overview
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ interface Position {
|
||||
method?: string;
|
||||
avg_price_currency?: string;
|
||||
stock_currency?: string;
|
||||
currency?: string;
|
||||
account_currency?: string;
|
||||
current_value_account?: number;
|
||||
total_pnl?: number;
|
||||
@@ -61,7 +62,7 @@ export default function PortfolioPage() {
|
||||
const opts = Array.isArray(data?.options) ? data.options : [];
|
||||
if (opts.length > 0) {
|
||||
setExchangeOptions((prev) => ({ ...prev, [rowKey]: opts }));
|
||||
const def = opts.find((o) => o.default) || opts[0];
|
||||
const def = opts.find((o: { default?: boolean; exchange: string }) => o.default) || opts[0];
|
||||
setExchangeSelections((prev) => ({ ...prev, [rowKey]: def.exchange }));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,81 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ResearchGridLayout } from "../components/research/ResearchGridLayout";
|
||||
import type { ResearchDashboardPayload } from "../components/research/types";
|
||||
import { useTicker } from "../lib/use-ticker";
|
||||
|
||||
interface PiotroskiData {
|
||||
score?: number;
|
||||
details?: Record<string, { pass: boolean; value: number }>;
|
||||
}
|
||||
|
||||
interface RadarData {
|
||||
roe?: number;
|
||||
roa?: number;
|
||||
gross_margin?: number;
|
||||
current_ratio?: number;
|
||||
revenue_growth?: number;
|
||||
}
|
||||
|
||||
export default function ResearchPage() {
|
||||
const { ticker } = useTicker();
|
||||
const [assetType, setAssetType] = useState<string>("equity");
|
||||
const [piotroski, setPiotroski] = useState<PiotroskiData | null>(null);
|
||||
const [radar, setRadar] = useState<RadarData | null>(null);
|
||||
const [aiAnalysis, setAiAnalysis] = useState<string>("");
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
const [dashboard, setDashboard] = useState<ResearchDashboardPayload | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
Promise.all([
|
||||
fetch(`/api/market/piotroski/${ticker}`).then((r) => r.ok ? r.json() : null),
|
||||
fetch(`/api/market/radar/${ticker}`).then((r) => r.ok ? r.json() : null),
|
||||
fetch(`/api/market/overview/${ticker}`).then((r) => r.ok ? r.json() : null),
|
||||
]).then(([p, r, o]) => {
|
||||
setPiotroski(p);
|
||||
setRadar(r);
|
||||
setAssetType(o?.asset_type || "equity");
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
fetch(`/api/market/overview/${ticker}`).then((r) => (r.ok ? r.json() : { asset_type: "equity" })),
|
||||
fetch(`/api/research/dashboard/${encodeURIComponent(ticker)}`).then(async (r) => {
|
||||
if (!r.ok) {
|
||||
const errText = await r.text();
|
||||
throw new Error(errText || `HTTP ${r.status}`);
|
||||
}
|
||||
return r.json();
|
||||
}),
|
||||
])
|
||||
.then(([overview, dash]) => {
|
||||
setAssetType(overview?.asset_type || "equity");
|
||||
setDashboard(dash as ResearchDashboardPayload);
|
||||
})
|
||||
.catch(() => {
|
||||
setLoadError("대시보드 데이터를 불러오지 못했습니다.");
|
||||
setDashboard(null);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [ticker]);
|
||||
|
||||
async function runAiAnalysis() {
|
||||
const apiKey = localStorage.getItem("atlas_gemini_key") || "";
|
||||
if (!apiKey) {
|
||||
setAiAnalysis("Please set your Gemini API key in Settings first.");
|
||||
return;
|
||||
}
|
||||
setAiLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/analysis/strategy", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ticker, question: `Comprehensive research analysis of ${ticker}: competitive position, growth catalysts, and risks`, api_key: apiKey }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAiAnalysis(typeof data === "string" ? data : data.analysis || JSON.stringify(data));
|
||||
} else {
|
||||
setAiAnalysis("API error. Check your Gemini key in Settings.");
|
||||
}
|
||||
} catch {
|
||||
setAiAnalysis("Connection error.");
|
||||
}
|
||||
setAiLoading(false);
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-accent-green animate-pulse font-mono">Loading research…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center h-64"><div className="text-accent-green animate-pulse font-mono">Loading...</div></div>;
|
||||
|
||||
const fScore = piotroski?.score ?? 0;
|
||||
const fColor = fScore >= 7 ? "text-accent-green" : fScore >= 4 ? "text-accent-yellow" : "text-accent-red";
|
||||
|
||||
const radarMetrics = radar ? [
|
||||
{ label: "ROE", value: radar.roe, max: 30 },
|
||||
{ label: "ROA", value: radar.roa, max: 20 },
|
||||
{ label: "Gross Margin", value: radar.gross_margin, max: 100 },
|
||||
{ label: "Current Ratio", value: radar.current_ratio, max: 3 },
|
||||
{ label: "Revenue Growth", value: radar.revenue_growth, max: 50 },
|
||||
] : [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">
|
||||
@@ -83,90 +51,38 @@ export default function ResearchPage() {
|
||||
</h1>
|
||||
|
||||
{assetType === "equity" ? (
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
{/* Piotroski F-Score */}
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">Piotroski F-Score</h3>
|
||||
<div className={`text-5xl font-mono font-bold ${fColor}`}>{fScore}/9</div>
|
||||
<div className="text-text-muted text-xs mt-2">
|
||||
{fScore >= 7 ? "Strong" : fScore >= 4 ? "Moderate" : "Weak"} financial strength
|
||||
loadError ? (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5 text-accent-red text-sm">{loadError}</div>
|
||||
) : dashboard ? (
|
||||
<>
|
||||
{dashboard.error && (
|
||||
<div className="mb-4 rounded-lg border border-accent-yellow/40 bg-bg-card px-4 py-3 text-sm text-accent-yellow">
|
||||
{dashboard.error} — 일부 위젯이 비어 있을 수 있습니다.
|
||||
</div>
|
||||
)}
|
||||
<ResearchGridLayout dashboard={dashboard} />
|
||||
</>
|
||||
) : (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5 text-text-muted text-sm">
|
||||
대시보드 데이터가 없습니다. API 응답을 확인하거나 티커를 바꿔 보세요.
|
||||
</div>
|
||||
{piotroski?.details && (
|
||||
<div className="mt-4 space-y-1.5">
|
||||
{Object.entries(piotroski.details).map(([key, val]) => (
|
||||
<div key={key} className="flex justify-between text-sm">
|
||||
<span className="text-text-muted">{key}</span>
|
||||
<span className={val.pass ? "text-accent-green" : "text-accent-red"}>
|
||||
{val.pass ? "✓" : "✗"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Financial Radar */}
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">Financial Radar</h3>
|
||||
{radarMetrics.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{radarMetrics.map((m) => {
|
||||
const pct = m.value != null ? Math.min((m.value / m.max) * 100, 100) : 0;
|
||||
const color = pct > 66 ? "bg-accent-green" : pct > 33 ? "bg-accent-yellow" : "bg-accent-red";
|
||||
return (
|
||||
<div key={m.label}>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-text-muted">{m.label}</span>
|
||||
<span className="text-text-primary font-mono">{m.value?.toFixed(1) ?? "—"}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-bg-primary rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${color} transition-all`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-text-muted">No data available</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : assetType === "etf" ? (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5 mb-6">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">ETF Research</h3>
|
||||
<div className="text-text-secondary text-sm">
|
||||
Holdings Analysis, Sector Breakdown, Overlap Analysis를 우선 제공합니다.
|
||||
Piotroski/F-Score 및 기업 재무 레이더는 ETF에 적용되지 않습니다.
|
||||
Holdings Analysis, Sector Breakdown, Overlap Analysis를 우선 제공합니다. Piotroski/F-Score 및 기업 재무
|
||||
대시보드는 ETF에 적용되지 않습니다.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5 mb-6">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">Commodity Research</h3>
|
||||
<div className="text-text-secondary text-sm">
|
||||
Seasonal Analysis와 Supply/Demand 요인을 중심으로 분석합니다.
|
||||
주식 전용 지표(F-Score, DuPont)는 표시하지 않습니다.
|
||||
Seasonal Analysis와 Supply/Demand 요인을 중심으로 분석합니다. 주식 전용 지표는 표시하지 않습니다.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Analysis */}
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-text-secondary text-sm font-semibold">AI Research Analysis</h3>
|
||||
<button
|
||||
onClick={runAiAnalysis}
|
||||
disabled={aiLoading}
|
||||
className="bg-accent-green text-bg-primary px-4 py-1.5 rounded-md text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{aiLoading ? "Analyzing..." : "Run Analysis"}
|
||||
</button>
|
||||
</div>
|
||||
{aiAnalysis ? (
|
||||
<pre className="text-text-primary text-sm whitespace-pre-wrap leading-relaxed font-sans">{aiAnalysis}</pre>
|
||||
) : (
|
||||
<div className="text-text-muted text-sm">Click "Run Analysis" to generate AI-powered research report.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface ScreenerRow {
|
||||
ticker: string;
|
||||
@@ -18,6 +19,11 @@ interface BacktestResult {
|
||||
benchmark_return_pct?: number;
|
||||
alpha?: number;
|
||||
sharpe_ratio?: number;
|
||||
max_drawdown_pct?: number;
|
||||
equity_curve?: number[];
|
||||
benchmark_curve?: number[];
|
||||
dates?: string[];
|
||||
benchmark_ticker?: string;
|
||||
}
|
||||
|
||||
export default function ScreenerPage() {
|
||||
@@ -29,11 +35,14 @@ export default function ScreenerPage() {
|
||||
const [divMin, setDivMin] = useState("");
|
||||
|
||||
const [btTicker, setBtTicker] = useState("AAPL");
|
||||
const [btBenchmark, setBtBenchmark] = useState("SPY");
|
||||
const [rebalanceMonths, setRebalanceMonths] = useState("");
|
||||
const [strategy, setStrategy] = useState("sma_crossover");
|
||||
const [startDate, setStartDate] = useState("2024-01-01");
|
||||
const [endDate, setEndDate] = useState("2026-03-01");
|
||||
const [btResult, setBtResult] = useState<BacktestResult | null>(null);
|
||||
const [btLoading, setBtLoading] = useState(false);
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
async function runScreener() {
|
||||
setLoading(true);
|
||||
@@ -59,15 +68,20 @@ export default function ScreenerPage() {
|
||||
async function runBacktest() {
|
||||
setBtLoading(true);
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
ticker: btTicker,
|
||||
strategy,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
benchmark_ticker: btBenchmark || "SPY",
|
||||
};
|
||||
const rm = parseInt(rebalanceMonths, 10);
|
||||
if (!Number.isNaN(rm) && rm > 0) body.rebalance_months = rm;
|
||||
|
||||
const res = await fetch("/api/screener/backtest", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ticker: btTicker,
|
||||
strategy,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
setBtResult(await res.json());
|
||||
} catch {
|
||||
@@ -77,18 +91,67 @@ export default function ScreenerPage() {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!btResult?.equity_curve || !btResult.benchmark_curve || !btResult.dates || !chartRef.current) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let chart: any = null;
|
||||
const el = chartRef.current;
|
||||
let onResize: (() => void) | null = null;
|
||||
|
||||
import("lightweight-charts")
|
||||
.then(({ createChart }) => {
|
||||
if (!el) return;
|
||||
el.innerHTML = "";
|
||||
chart = createChart(el, {
|
||||
width: el.clientWidth,
|
||||
height: 320,
|
||||
layout: { background: { color: "#1A1A26" }, textColor: "#9CA3AF" },
|
||||
grid: { vertLines: { color: "#2A2A3A" }, horzLines: { color: "#2A2A3A" } },
|
||||
crosshair: { mode: 0 },
|
||||
timeScale: { borderColor: "#2A2A3A" },
|
||||
});
|
||||
const strat = chart.addLineSeries({ color: "#00D4AA", lineWidth: 2 });
|
||||
strat.setData(
|
||||
btResult.dates!.map((d, i) => ({
|
||||
time: d as string & { __brand?: "Time" },
|
||||
value: btResult.equity_curve![i],
|
||||
}))
|
||||
);
|
||||
const bench = chart.addLineSeries({ color: "#4DA6FF", lineWidth: 2 });
|
||||
bench.setData(
|
||||
btResult.dates!.map((d, i) => ({
|
||||
time: d as string & { __brand?: "Time" },
|
||||
value: btResult.benchmark_curve![i],
|
||||
}))
|
||||
);
|
||||
chart.timeScale().fitContent();
|
||||
onResize = () => {
|
||||
if (el && chart) chart.applyOptions({ width: el.clientWidth });
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
if (onResize) window.removeEventListener("resize", onResize);
|
||||
if (chart) chart.remove();
|
||||
};
|
||||
}, [btResult]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Stock Screener</h1>
|
||||
|
||||
<div className="flex gap-1 mb-4 bg-bg-card rounded-lg p-1 w-fit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("screener")}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium ${tab === "screener" ? "bg-accent-green text-bg-primary" : "text-text-secondary"}`}
|
||||
>
|
||||
Screener
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("backtest")}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium ${tab === "backtest" ? "bg-accent-green text-bg-primary" : "text-text-secondary"}`}
|
||||
>
|
||||
@@ -99,10 +162,25 @@ export default function ScreenerPage() {
|
||||
{tab === "screener" ? (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4">
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
<input value={peMax} onChange={(e) => setPeMax(e.target.value)} placeholder="P/E < 25" className="bg-bg-primary border border-border rounded px-3 py-2 text-sm" />
|
||||
<input value={sector} onChange={(e) => setSector(e.target.value)} placeholder="Sector (optional)" className="bg-bg-primary border border-border rounded px-3 py-2 text-sm" />
|
||||
<input value={divMin} onChange={(e) => setDivMin(e.target.value)} placeholder="Div Yield > %" className="bg-bg-primary border border-border rounded px-3 py-2 text-sm" />
|
||||
<button onClick={runScreener} className="bg-accent-green text-bg-primary px-4 py-2 rounded font-semibold">
|
||||
<input
|
||||
value={peMax}
|
||||
onChange={(e) => setPeMax(e.target.value)}
|
||||
placeholder="P/E < 25"
|
||||
className="bg-bg-primary border border-border rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={sector}
|
||||
onChange={(e) => setSector(e.target.value)}
|
||||
placeholder="Sector (optional)"
|
||||
className="bg-bg-primary border border-border rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={divMin}
|
||||
onChange={(e) => setDivMin(e.target.value)}
|
||||
placeholder="Div Yield > %"
|
||||
className="bg-bg-primary border border-border rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
<button type="button" onClick={runScreener} className="bg-accent-green text-bg-primary px-4 py-2 rounded font-semibold">
|
||||
{loading ? "Running..." : "Run Screener"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -111,7 +189,9 @@ export default function ScreenerPage() {
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
{["Ticker", "Name", "Sector", "Price", "P/E", "MCap", "Change"].map((h) => (
|
||||
<th key={h} className="text-left px-3 py-2 text-text-muted">{h}</th>
|
||||
<th key={h} className="text-left px-3 py-2 text-text-muted">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -124,7 +204,7 @@ export default function ScreenerPage() {
|
||||
<td className="px-3 py-2 text-text-primary font-mono">{r.price != null ? `$${r.price.toFixed(2)}` : "—"}</td>
|
||||
<td className="px-3 py-2 text-text-primary font-mono">{r.pe != null ? r.pe.toFixed(1) : "—"}</td>
|
||||
<td className="px-3 py-2 text-text-primary font-mono">{r.market_cap ? `${(r.market_cap / 1e9).toFixed(1)}B` : "—"}</td>
|
||||
<td className={`px-3 py-2 font-mono ${((r.change_pct || 0) >= 0) ? "text-accent-green" : "text-accent-red"}`}>
|
||||
<td className={`px-3 py-2 font-mono ${(r.change_pct || 0) >= 0 ? "text-accent-green" : "text-accent-red"}`}>
|
||||
{r.change_pct != null ? `${r.change_pct >= 0 ? "+" : ""}${r.change_pct.toFixed(2)}%` : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -134,9 +214,26 @@ export default function ScreenerPage() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4">
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4 space-y-4">
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
<input value={btTicker} onChange={(e) => setBtTicker(e.target.value.toUpperCase())} placeholder="Ticker" className="bg-bg-primary border border-border rounded px-3 py-2 text-sm" />
|
||||
<input
|
||||
value={btTicker}
|
||||
onChange={(e) => setBtTicker(e.target.value.toUpperCase())}
|
||||
placeholder="Ticker"
|
||||
className="bg-bg-primary border border-border rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={btBenchmark}
|
||||
onChange={(e) => setBtBenchmark(e.target.value.toUpperCase())}
|
||||
placeholder="Benchmark (e.g. SPY)"
|
||||
className="bg-bg-primary border border-border rounded px-3 py-2 text-sm w-36"
|
||||
/>
|
||||
<input
|
||||
value={rebalanceMonths}
|
||||
onChange={(e) => setRebalanceMonths(e.target.value)}
|
||||
placeholder="Rebal. months (opt)"
|
||||
className="bg-bg-primary border border-border rounded px-3 py-2 text-sm w-40"
|
||||
/>
|
||||
<select value={strategy} onChange={(e) => setStrategy(e.target.value)} className="bg-bg-primary border border-border rounded px-3 py-2 text-sm">
|
||||
<option value="sma_crossover">SMA Crossover</option>
|
||||
<option value="rsi_oversold">RSI Oversold</option>
|
||||
@@ -144,17 +241,24 @@ export default function ScreenerPage() {
|
||||
</select>
|
||||
<input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} className="bg-bg-primary border border-border rounded px-3 py-2 text-sm" />
|
||||
<input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} className="bg-bg-primary border border-border rounded px-3 py-2 text-sm" />
|
||||
<button onClick={runBacktest} className="bg-accent-green text-bg-primary px-4 py-2 rounded font-semibold">
|
||||
<button type="button" onClick={runBacktest} className="bg-accent-green text-bg-primary px-4 py-2 rounded font-semibold">
|
||||
{btLoading ? "Running..." : "Run Backtest"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-text-muted text-xs">
|
||||
Strategy (green) vs benchmark buy‑hold (blue). Optional rebalance: hold signal fixed for N calendar months, then refresh.
|
||||
</p>
|
||||
{btResult && !btResult.error && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<Metric label="Return" value={`${btResult.total_return_pct}%`} />
|
||||
<Metric label="Benchmark" value={`${btResult.benchmark_return_pct}%`} />
|
||||
<Metric label="Alpha" value={`${btResult.alpha}%`} />
|
||||
<Metric label="Sharpe" value={`${btResult.sharpe_ratio}`} />
|
||||
</div>
|
||||
<>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<Metric label="Return" value={`${btResult.total_return_pct}%`} />
|
||||
<Metric label={`Bench (${btResult.benchmark_ticker || btBenchmark})`} value={`${btResult.benchmark_return_pct}%`} />
|
||||
<Metric label="Alpha" value={`${btResult.alpha}%`} />
|
||||
<Metric label="Sharpe" value={`${btResult.sharpe_ratio}`} />
|
||||
<Metric label="Max DD" value={`${btResult.max_drawdown_pct}%`} />
|
||||
</div>
|
||||
<div ref={chartRef} className="w-full min-h-[320px] rounded-lg border border-border overflow-hidden" />
|
||||
</>
|
||||
)}
|
||||
{btResult?.error && <p className="text-accent-red text-sm">{btResult.error}</p>}
|
||||
</div>
|
||||
|
||||
@@ -56,6 +56,8 @@ export default function TechnicalPage() {
|
||||
// Render chart using lightweight-charts
|
||||
useEffect(() => {
|
||||
if (!chartRef.current || bars.length === 0) return;
|
||||
// v5 typings omit series helpers; runtime still exposes addCandlestickSeries etc.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let chart: any = null;
|
||||
(async () => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user