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:
shawnkim1997
2026-03-26 21:39:07 +00:00
co-authored by Claude Opus 4.6
parent 38c56a5a43
commit 51cbaf7f8d
98 changed files with 7999 additions and 2399 deletions
@@ -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">&uarr;</span>;
if (dir === "down") return <span className="text-accent-red">&darr;</span>;
if (dir === "flat") return <span className="text-text-muted">&rarr;</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: "🎯" },
+35
View File
@@ -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>
);
}
+272 -84
View File
@@ -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;
}
+3 -12
View File
@@ -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 &amp; Smart Money</h1>
<p className="text-text-muted text-sm">
Growth vs inflation quadrant (FRED / OECD), USpeer 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 &amp; 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>
);
}
+257 -58
View File
@@ -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 }));
}
});
+48 -132
View File
@@ -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 &quot;Run Analysis&quot; to generate AI-powered research report.</div>
)}
</div>
</div>
);
}
+126 -22
View File
@@ -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 buyhold (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 {