Refresh Morgan Terminal UI and README media

This commit is contained in:
shawnkim1997
2026-04-16 11:15:23 +01:00
parent ec2c5b37a2
commit 25b2ad7e15
68 changed files with 2941 additions and 816 deletions
@@ -16,7 +16,7 @@ const SidebarClient = dynamic(
ssr: false,
loading: () => (
<aside
className="w-[260px] fixed top-[52px] bottom-0 left-0 z-40 border-r border-border bg-bg-primary"
className="fixed bottom-0 left-0 top-[56px] z-40 w-[260px] border-r border-border bg-surface-raised"
aria-hidden
/>
),
@@ -27,9 +27,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
return (
<>
<TickerBar />
<div className="flex min-h-screen pt-[52px]">
<div className="flex min-h-screen bg-surface-canvas pt-[56px]">
<SidebarClient />
<main className="flex-1 ml-[260px] mr-[380px] p-7 bg-bg-primary min-h-[calc(100vh-52px)] transition-all duration-200">
<main className="ml-[260px] mr-[380px] min-h-[calc(100vh-56px)] flex-1 bg-transparent p-7 transition-all duration-200">
{children}
</main>
<ChatPanel />
@@ -1,23 +1,14 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Bot, SendHorizontal } from "lucide-react";
import { useTicker } from "../lib/use-ticker";
export function ChatPanel() {
const [messages, setMessages] = useState<Array<{ role: string; content: string }>>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [ticker, setTicker] = useState("AAPL");
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const saved = localStorage.getItem("atlas_active_ticker");
if (saved) setTicker(saved);
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail) setTicker(detail);
};
window.addEventListener("atlas-ticker-change", handler);
return () => window.removeEventListener("atlas-ticker-change", handler);
}, []);
const { ticker } = useTicker();
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
@@ -60,25 +51,32 @@ export function ChatPanel() {
];
return (
<aside className="w-[380px] bg-bg-secondary border-l border-border fixed top-[52px] bottom-0 right-0 flex flex-col z-40">
<div className="p-4 border-b border-border font-bold text-lg text-text-primary">
🤖 AI Copilot
<aside className="fixed bottom-0 right-0 top-[56px] z-40 flex w-[380px] flex-col border-l border-border bg-surface-raised">
<div className="border-b border-border bg-surface-sunken px-4 py-4">
<div className="flex items-center gap-3">
<div className="rounded-full bg-brand-navy/10 p-2 text-brand-navy">
<Bot className="h-5 w-5" />
</div>
<div>
<div className="font-serif text-lg font-bold text-brand-navy">AI Copilot</div>
<div className="text-xs uppercase tracking-[0.12em] text-text-muted">Context: {ticker}</div>
</div>
</div>
</div>
<div ref={scrollRef} className="flex-1 p-4 overflow-y-auto flex flex-col gap-3">
<div ref={scrollRef} className="flex flex-1 flex-col gap-3 overflow-y-auto bg-transparent p-4">
{messages.length === 0 ? (
<div className="text-text-secondary text-sm">
<div className="text-sm text-text-secondary">
<p>
Ask me anything about{" "}
<span className="text-accent-green font-semibold">{ticker}</span>.
Ask me anything about <span className="font-semibold text-brand-navy">{ticker}</span>.
</p>
<p className="mt-3 font-semibold text-text-primary">Try:</p>
<ul className="flex flex-col gap-1.5 mt-2">
<p className="mt-4 text-xs font-semibold uppercase tracking-[0.12em] text-text-muted">Suggested prompts</p>
<ul className="mt-2 flex flex-col gap-2">
{suggestions.map((q) => (
<li
key={q}
onClick={() => setInput(q)}
className="px-3 py-2.5 bg-bg-card rounded-lg cursor-pointer text-sm text-text-primary hover:bg-bg-hover transition-colors"
className="cursor-pointer rounded-md border border-border bg-surface-raised px-3 py-2.5 text-sm text-text-primary shadow-card transition-colors hover:bg-surface-sunken"
>
{q}
</li>
@@ -89,32 +87,33 @@ export function ChatPanel() {
messages.map((m, i) => (
<div
key={i}
className={`px-3.5 py-2.5 rounded-lg text-sm leading-relaxed max-w-[90%] whitespace-pre-wrap ${
className={`max-w-[90%] whitespace-pre-wrap rounded-md px-3.5 py-2.5 text-sm leading-relaxed shadow-card ${
m.role === "user"
? "bg-bg-card text-text-primary self-end"
: "bg-accent-green/10 text-text-primary self-start"
? "self-end bg-brand-navy text-white"
: "self-start border border-border bg-surface-raised text-text-primary"
}`}
>
{m.content}
</div>
))
)}
{loading && <div className="text-accent-green text-sm animate-pulse">Thinking...</div>}
{loading && <div className="text-sm text-brand-blue animate-pulse">Thinking...</div>}
</div>
<div className="p-3 border-t border-border">
<div className="flex gap-2 bg-bg-card rounded-lg border border-border px-3.5 py-2.5">
<div className="border-t border-border bg-surface-raised p-3">
<div className="flex gap-2 rounded-md border border-border bg-surface-sunken px-3.5 py-2.5">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="Ask anything..."
className="flex-1 bg-transparent border-none text-text-primary outline-none"
className="flex-1 border-none bg-transparent text-text-primary outline-none"
/>
<button
onClick={handleSend}
className="bg-accent-green text-bg-primary border-none rounded-md px-4 py-1.5 font-semibold cursor-pointer text-sm hover:opacity-90 transition-opacity"
className="inline-flex items-center gap-1 rounded-md border-none bg-brand-navy px-4 py-1.5 text-sm font-semibold text-white transition-opacity hover:bg-brand-blue"
>
<SendHorizontal className="h-4 w-4" />
Send
</button>
</div>
@@ -12,6 +12,7 @@ import {
XAxis,
YAxis,
} from "recharts";
import { chartPalette } from "../../lib/chart-theme";
export interface QuadrantPoint {
id: string;
@@ -32,10 +33,10 @@ const FLAG: Record<string, string> = {
};
const QUADRANT_COLOR: Record<string, string> = {
Reflation: "#FFD93D",
Recovery: "#00D4AA",
Stagflation: "#FF4757",
Overheat: "#4DA6FF",
Reflation: chartPalette.gold,
Recovery: chartPalette.green,
Stagflation: chartPalette.red,
Overheat: chartPalette.blue,
};
function QuadrantTooltip({
@@ -54,8 +55,8 @@ function QuadrantTooltip({
{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 className="text-brand-navy">Growth Z: {p.growth_z?.toFixed(2)}</div>
<div className="text-brand-blue">Inflation Z: {p.inflation_z?.toFixed(2)}</div>
</div>
);
}
@@ -73,31 +74,31 @@ export function GlobalMacroQuadrantChart({ points }: { points: QuadrantPoint[] }
<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" />
<CartesianGrid strokeDasharray="3 3" stroke={chartPalette.grid} />
<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 }}
stroke={chartPalette.textMuted}
tick={{ fill: chartPalette.textMuted, fontSize: 11 }}
label={{ value: "Growth momentum (Z)", position: "bottom", fill: chartPalette.textMuted, 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 }}
stroke={chartPalette.textMuted}
tick={{ fill: chartPalette.textMuted, fontSize: 11 }}
label={{ value: "Inflation momentum (Z)", angle: -90, position: "insideLeft", fill: chartPalette.textMuted, fontSize: 11 }}
/>
<ReferenceLine x={0} stroke="#4B5563" strokeDasharray="4 4" />
<ReferenceLine y={0} stroke="#4B5563" strokeDasharray="4 4" />
<ReferenceLine x={0} stroke={chartPalette.neutral} strokeDasharray="4 4" />
<ReferenceLine y={0} stroke={chartPalette.neutral} strokeDasharray="4 4" />
<Tooltip content={<QuadrantTooltip />} cursor={{ strokeDasharray: "3 3" }} />
<Scatter data={points} fill="#00D4AA" name="Country">
<Scatter data={points} fill={chartPalette.green} name="Country">
{points.map((entry) => (
<Cell key={entry.id} fill={QUADRANT_COLOR[entry.quadrant] ?? "#00D4AA"} />
<Cell key={entry.id} fill={QUADRANT_COLOR[entry.quadrant] ?? chartPalette.green} />
))}
<LabelList dataKey="id" position="top" fill="#E5E7EB" fontSize={11} fontFamily="monospace" />
<LabelList dataKey="id" position="top" fill={chartPalette.text} fontSize={11} fontFamily="monospace" />
</Scatter>
</ScatterChart>
</ResponsiveContainer>
@@ -10,6 +10,7 @@ import {
XAxis,
YAxis,
} from "recharts";
import { chartPalette } from "../../lib/chart-theme";
export interface CopperGoldRow {
date: string;
@@ -34,7 +35,7 @@ function RoroGauge({ z, label }: { z: number | null; label: string | null }) {
<path
d="M 30 100 A 70 70 0 0 1 170 100"
fill="none"
stroke="#2A2A3A"
stroke={chartPalette.grid}
strokeWidth="10"
strokeLinecap="round"
/>
@@ -48,21 +49,21 @@ function RoroGauge({ z, label }: { z: number | null; label: string | null }) {
/>
<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" />
<stop offset="0%" stopColor={chartPalette.red} />
<stop offset="50%" stopColor={chartPalette.gold} />
<stop offset="100%" stopColor={chartPalette.green} />
</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">
<line x1={cx} y1={cy} x2={x2} y2={y2} stroke={chartPalette.text} strokeWidth="3" strokeLinecap="round" />
<circle cx={cx} cy={cy} r="6" fill={chartPalette.navy} />
<text x="30" y="108" fill={chartPalette.textMuted} fontSize="9" fontFamily="monospace">
Fear
</text>
<text x="150" y="108" fill="#6B7280" fontSize="9" fontFamily="monospace">
<text x="150" y="108" fill={chartPalette.textMuted} fontSize="9" fontFamily="monospace">
Greed
</text>
</svg>
<p className="text-accent-green font-mono text-lg mt-1">{label ?? "—"}</p>
<p className="mt-1 font-mono text-lg text-brand-navy">{label ?? "—"}</p>
<p className="text-text-muted text-xs font-mono">Z = {z != null ? z.toFixed(2) : "—"}</p>
</div>
);
@@ -79,7 +80,7 @@ export function SmartMoneyPanel({
}) {
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">
<div className="flex items-center justify-center rounded-lg border border-border/50 bg-surface-sunken p-4">
<RoroGauge z={roroZ} label={roroLabel} />
</div>
<div className="min-h-[260px]">
@@ -91,19 +92,19 @@ export function SmartMoneyPanel({
) : (
<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"]} />
<CartesianGrid strokeDasharray="3 3" stroke={chartPalette.grid} />
<XAxis dataKey="date" tick={{ fill: chartPalette.textMuted, fontSize: 9 }} minTickGap={32} />
<YAxis tick={{ fill: chartPalette.textMuted, fontSize: 10 }} domain={["auto", "auto"]} />
<Tooltip
contentStyle={{ background: "#1A1A26", border: "1px solid #2A2A3A", fontSize: 12 }}
contentStyle={{ background: chartPalette.canvas, border: `1px solid ${chartPalette.grid}`, 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" name="Cu/Au" stroke={chartPalette.gold} dot={false} strokeWidth={2} />
<Line
type="monotone"
dataKey="ratio_ma20"
name="MA20"
stroke="#4DA6FF"
stroke={chartPalette.blue}
dot={false}
strokeWidth={1.5}
/>
@@ -10,6 +10,7 @@ import {
XAxis,
YAxis,
} from "recharts";
import { chartPalette } from "../../lib/chart-theme";
export interface YieldFxRow {
date: string;
@@ -45,24 +46,24 @@ export function YieldFxDualAxisChart({
<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} />
<CartesianGrid strokeDasharray="3 3" stroke={chartPalette.grid} />
<XAxis dataKey="date" tick={{ fill: chartPalette.textMuted, fontSize: 10 }} minTickGap={24} />
<YAxis
yAxisId="left"
tick={{ fill: "#00D4AA", fontSize: 10 }}
tick={{ fill: chartPalette.navy, fontSize: 10 }}
domain={["auto", "auto"]}
label={{ value: "Spread (ppt)", angle: -90, position: "insideLeft", fill: "#00D4AA", fontSize: 10 }}
label={{ value: "Spread (ppt)", angle: -90, position: "insideLeft", fill: chartPalette.navy, fontSize: 10 }}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fill: "#4DA6FF", fontSize: 10 }}
tick={{ fill: chartPalette.blue, fontSize: 10 }}
domain={["auto", "auto"]}
label={{ value: meta.fx, angle: 90, position: "insideRight", fill: "#4DA6FF", fontSize: 10 }}
label={{ value: meta.fx, angle: 90, position: "insideRight", fill: chartPalette.blue, fontSize: 10 }}
/>
<Tooltip
contentStyle={{ background: "#1A1A26", border: "1px solid #2A2A3A", fontSize: 12 }}
labelStyle={{ color: "#F3F4F6" }}
contentStyle={{ background: chartPalette.canvas, border: `1px solid ${chartPalette.grid}`, fontSize: 12 }}
labelStyle={{ color: chartPalette.text }}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
<Line
@@ -70,7 +71,7 @@ export function YieldFxDualAxisChart({
type="monotone"
dataKey="spread_pct"
name="US10Y peer (ppt)"
stroke="#00D4AA"
stroke={chartPalette.navy}
dot={false}
strokeWidth={2}
/>
@@ -79,7 +80,7 @@ export function YieldFxDualAxisChart({
type="monotone"
dataKey="fx"
name={meta.fx}
stroke="#4DA6FF"
stroke={chartPalette.blue}
dot={false}
strokeWidth={2}
/>
@@ -3,6 +3,9 @@
import { useEffect, useState } from "react";
import { KpiSection, type KpiHistoryData } from "./KpiSection";
import { PeerComparison, type PeerComparisonData } from "./PeerComparison";
import { Card } from "../ui/Card";
import { SectionHeading } from "../ui/SectionHeading";
import { StatCard } from "../ui/StatCard";
interface EquityOverviewProps {
ticker: string;
@@ -43,31 +46,25 @@ export function EquityOverview({ ticker, sector, health }: EquityOverviewProps)
];
return (
<div>
<h1 className="text-2xl font-bold mb-1">
<span className="text-accent-green">{ticker}</span> Overview
</h1>
<div className="atlas-page">
<SectionHeading level={1}>{ticker} Overview</SectionHeading>
{sector?.current_price != null && (
<p className="text-3xl font-mono font-bold text-text-primary mb-6">${Number(sector.current_price).toFixed(2)}</p>
<p className="text-3xl font-mono font-bold text-text-primary">${Number(sector.current_price).toFixed(2)}</p>
)}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
{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 mb-1">{m.label}</div>
<div className="text-text-primary font-semibold">{m.value}</div>
</div>
<StatCard key={m.label} label={m.label} value={m.value} />
))}
</div>
<ConsensusGauge sector={sector} />
<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) : "—"} />
<Card title="Interest Cov." value={health?.interest_coverage != null ? `${Number(health.interest_coverage).toFixed(1)}x` : "—"} />
<Card title="D/E Ratio" value={health?.debt_to_equity != null ? Number(health.debt_to_equity).toFixed(2) : "—"} />
<div className="grid grid-cols-1 gap-4 mb-4 lg:grid-cols-4">
<OverviewStat title="Altman Z-Score" value={health?.altman_z != null ? Number(health.altman_z).toFixed(2) : "—"} />
<OverviewStat title="Current Ratio" value={health?.current_ratio != null ? Number(health.current_ratio).toFixed(2) : "—"} />
<OverviewStat title="Interest Cov." value={health?.interest_coverage != null ? `${Number(health.interest_coverage).toFixed(1)}x` : "—"} />
<OverviewStat title="D/E Ratio" value={health?.debt_to_equity != null ? Number(health.debt_to_equity).toFixed(2) : "—"} />
</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>
<Card title="DuPont Analysis">
{!!health?.dupont && typeof health.dupont === "object" && health.dupont !== null ? (
<div className="space-y-2">
{[
@@ -85,7 +82,7 @@ export function EquityOverview({ ticker, sector, health }: EquityOverviewProps)
) : (
<div className="text-text-muted">No data</div>
)}
</div>
</Card>
<PeerComparison currentTicker={ticker} data={peerData} />
</div>
);
@@ -113,9 +110,8 @@ function ConsensusGauge({ sector }: { sector: Record<string, unknown> | null })
const targetPct = range > 0 ? Math.max(0, Math.min(100, ((target - gaugeLow) / range) * 100)) : 50;
return (
<div className="bg-bg-card border border-border rounded-lg p-5 mb-6">
<Card title="Analyst Consensus" className="mb-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-text-secondary text-sm font-semibold">Analyst Consensus</h3>
{count != null && <span className="text-text-muted text-xs">{count} analysts</span>}
</div>
<div className="flex items-baseline gap-4 mb-4">
@@ -135,25 +131,24 @@ function ConsensusGauge({ sector }: { sector: Record<string, unknown> | null })
</div>
</div>
{/* Visual gauge bar */}
<div className="relative h-2 bg-bg-hover rounded-full mb-2">
<div className="relative mb-2 h-2 rounded-full bg-surface-sunken">
{/* target marker */}
<div className="absolute top-0 h-2 w-0.5 bg-accent-yellow" style={{ left: `${targetPct}%` }} />
<div className="absolute top-0 h-2 w-0.5 bg-brand-gold" style={{ left: `${targetPct}%` }} />
{/* current price marker */}
<div className="absolute -top-1 h-4 w-1 bg-accent-green rounded-sm" style={{ left: `${currentPct}%` }} />
<div className="absolute -top-1 h-4 w-1 rounded-sm bg-brand-navy" style={{ left: `${currentPct}%` }} />
</div>
<div className="flex justify-between text-text-muted text-xs font-mono">
<span>${gaugeLow.toFixed(0)}</span>
<span>${gaugeHigh.toFixed(0)}</span>
</div>
</div>
</Card>
);
}
function Card({ title, value }: { title: string; value: string }) {
function OverviewStat({ title, value }: { title: string; value: string }) {
return (
<div className="bg-bg-card border border-border rounded-lg p-5">
<h3 className="text-text-secondary text-sm font-semibold mb-3">{title}</h3>
<Card title={title}>
<div className="text-3xl font-mono font-bold text-text-primary">{value}</div>
</div>
</Card>
);
}
@@ -1,5 +1,7 @@
"use client";
import { chartPalette } from "../../lib/chart-theme";
export interface KpiHistoryData {
ticker: string;
quarters: string[];
@@ -63,8 +65,8 @@ function Sparkline({ values }: { values: Array<number | null> }) {
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" />
<path d={path} fill="none" stroke={chartPalette.blue} strokeWidth="2" strokeLinecap="round" />
<circle cx={lastX} cy={lastY} r="3" fill={chartPalette.gold} />
</svg>
);
}
@@ -1,5 +1,6 @@
"use client";
import { chartPalette } from "../../lib/chart-theme";
import type { FScoreCriterionSeries } from "./types";
function BinarySparkline({ history }: { history: { year: number; pass_flag: boolean }[] }) {
@@ -19,14 +20,14 @@ function BinarySparkline({ history }: { history: { year: number; pass_flag: bool
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" />
<path d={lineD} fill="none" stroke={chartPalette.neutral} 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"}
fill={p.pt.pass_flag ? chartPalette.green : chartPalette.red}
/>
))}
</svg>
@@ -1,20 +1,21 @@
"use client";
import { ResponsiveSankey } from "@nivo/sankey";
import { chartPalette } from "../../lib/chart-theme";
import type { SankeyNivoLink, SankeyNivoNode } from "./types";
const nivoTheme = {
background: "transparent",
text: { fill: "#9CA3AF", fontSize: 11 },
text: { fill: chartPalette.textMuted, fontSize: 11 },
tooltip: {
container: {
background: "#1A1A26",
color: "#E5E7EB",
background: chartPalette.canvas,
color: chartPalette.text,
fontSize: 12,
border: "1px solid #374151",
border: `1px solid ${chartPalette.grid}`,
},
},
labels: { text: { fill: "#D1D5DB" } },
labels: { text: { fill: chartPalette.text } },
};
export function SankeyWidget({
@@ -1,27 +1,76 @@
"use client";
import { ResponsiveBar } from "@nivo/bar";
import { chartPalette } from "../../lib/chart-theme";
import type { WaterfallStep } from "./types";
const barTheme = {
background: "transparent",
text: { fill: "#9CA3AF", fontSize: 11 },
text: { fill: chartPalette.textMuted, fontSize: 11 },
axis: {
domain: { line: { stroke: "#374151" } },
ticks: { line: { stroke: "#374151" }, text: { fill: "#9CA3AF" } },
legend: { text: { fill: "#9CA3AF" } },
domain: { line: { stroke: chartPalette.grid } },
ticks: { line: { stroke: chartPalette.grid }, text: { fill: chartPalette.textMuted } },
legend: { text: { fill: chartPalette.textMuted } },
},
grid: { line: { stroke: "#2D2D3A" } },
grid: { line: { stroke: chartPalette.grid } },
tooltip: {
container: {
background: "#1A1A26",
color: "#E5E7EB",
background: chartPalette.canvas,
color: chartPalette.text,
fontSize: 12,
border: "1px solid #374151",
border: `1px solid ${chartPalette.grid}`,
},
},
};
function formatCompactNumber(value: number): string {
const abs = Math.abs(value);
if (abs >= 1e9) {
return `${(value / 1e9).toLocaleString(undefined, { maximumFractionDigits: 1 })}B`;
}
if (abs >= 1e6) {
return `${(value / 1e6).toLocaleString(undefined, { maximumFractionDigits: 0 })}M`;
}
if (abs >= 1e3) {
return `${(value / 1e3).toLocaleString(undefined, { maximumFractionDigits: 0 })}K`;
}
return value.toLocaleString();
}
function niceStep(rawStep: number): number {
if (!Number.isFinite(rawStep) || rawStep <= 0) return 1;
const exponent = Math.floor(Math.log10(rawStep));
const fraction = rawStep / 10 ** exponent;
if (fraction <= 1) return 10 ** exponent;
if (fraction <= 2) return 2 * 10 ** exponent;
if (fraction <= 5) return 5 * 10 ** exponent;
return 10 * 10 ** exponent;
}
function buildTickValues(values: number[], maxTicks = 5): number[] {
if (!values.length) return [0];
const min = Math.min(...values, 0);
const max = Math.max(...values, 0);
const span = max - min;
if (span === 0) {
return [min];
}
const step = niceStep(span / Math.max(1, maxTicks - 1));
const start = Math.floor(min / step) * step;
const end = Math.ceil(max / step) * step;
const ticks: number[] = [];
for (let current = start; current <= end + step / 2; current += step) {
ticks.push(Number(current.toFixed(6)));
}
return ticks.slice(0, maxTicks + 1);
}
export function WaterfallWidget({ steps }: { steps: WaterfallStep[] }) {
if (!steps.length) {
return (
@@ -37,6 +86,7 @@ export function WaterfallWidget({ steps }: { steps: WaterfallStep[] }) {
delta: s.value,
type: s.step_type,
}));
const tickValues = buildTickValues(data.map((d) => d.delta));
return (
<div className="w-full h-[min(380px,50vh)] min-h-[240px]">
@@ -45,32 +95,26 @@ export function WaterfallWidget({ steps }: { steps: WaterfallStep[] }) {
keys={["delta"]}
indexBy="label"
layout="horizontal"
margin={{ top: 8, right: 28, bottom: 40, left: 160 }}
margin={{ top: 8, right: 28, bottom: 56, 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";
if (r.type === "total") return chartPalette.navy;
return (r.delta ?? 0) >= 0 ? chartPalette.green : chartPalette.red;
}}
borderRadius={2}
axisTop={null}
axisRight={null}
axisBottom={{
tickSize: 0,
tickPadding: 8,
tickPadding: 6,
tickValues,
legend: "USD (reported units)",
legendPosition: "middle",
legendOffset: 32,
format: (v) => {
const n = Number(v);
const abs = Math.abs(n);
if (abs >= 1e9) return `${(n / 1e9).toLocaleString(undefined, { maximumFractionDigits: 1 })}B`;
if (abs >= 1e6) return `${(n / 1e6).toLocaleString(undefined, { maximumFractionDigits: 0 })}M`;
if (abs >= 1e3) return `${(n / 1e3).toLocaleString(undefined, { maximumFractionDigits: 0 })}K`;
return n.toLocaleString();
},
legendOffset: 44,
format: (v) => formatCompactNumber(Number(v)),
}}
axisLeft={{
tickSize: 0,
@@ -87,7 +131,7 @@ export function WaterfallWidget({ steps }: { steps: WaterfallStep[] }) {
}}
labelSkipWidth={12}
labelSkipHeight={12}
labelTextColor="#E5E7EB"
labelTextColor={chartPalette.text}
theme={barTheme}
tooltip={({ value, indexValue }) => (
<div className="px-2 py-1 text-xs">
@@ -1,100 +1,97 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState, useEffect } from "react";
import { normalizeTickerInput } from "../lib/ticker-alias";
import { BarChart3, Briefcase, CalendarRange, FileSearch, FileText, Globe, Landmark, LineChart, Microscope, Newspaper, Search, Settings, Sparkles, Target, TrendingUp } from "lucide-react";
import { useState } from "react";
import { useTicker } from "../lib/use-ticker";
const NAV_ITEMS = [
{ href: "/", label: "Overview", icon: "📊" },
{ href: "/research", label: "Research", icon: "🔬" },
{ 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: "🎯" },
{ href: "/portfolio", label: "Portfolio", icon: "💼" },
{ href: "/filings", label: "Filings", icon: "📑" },
{ href: "/report", label: "Report", icon: "🏦" },
{ href: "/", label: "Overview", icon: BarChart3 },
{ href: "/research", label: "Research", icon: Microscope },
{ href: "/valuation", label: "Valuation", icon: Landmark },
{ href: "/technical", label: "Technical", icon: TrendingUp },
{ href: "/markets", label: "Markets", icon: Globe },
{ href: "/macro", label: "Macro", icon: LineChart },
{ href: "/earnings", label: "Earnings", icon: CalendarRange },
{ href: "/news", label: "News", icon: Newspaper },
{ href: "/screener", label: "Screener", icon: Target },
{ href: "/portfolio", label: "Portfolio", icon: Briefcase },
{ href: "/filings", label: "Filings", icon: FileSearch },
{ href: "/report", label: "Report", icon: FileText },
];
export function Sidebar() {
const pathname = usePathname();
const [input, setInput] = useState("");
const [ticker, setTickerLocal] = useState("AAPL");
useEffect(() => {
const saved = localStorage.getItem("atlas_active_ticker");
if (saved) setTickerLocal(saved);
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail) setTickerLocal(detail);
};
window.addEventListener("atlas-ticker-change", handler);
return () => window.removeEventListener("atlas-ticker-change", handler);
}, []);
const { ticker, setTicker } = useTicker();
function handleSearch() {
const val = normalizeTickerInput(input);
const val = input.trim();
if (val) {
setTickerLocal(val);
localStorage.setItem("atlas_active_ticker", val);
window.dispatchEvent(new CustomEvent("atlas-ticker-change", { detail: val }));
setTicker(val);
setInput("");
}
}
return (
<aside className="w-[260px] bg-bg-primary border-r border-border p-4 flex flex-col gap-2 fixed top-[52px] bottom-0 left-0 overflow-y-auto z-40">
{/* Ticker Search */}
<div>
<div className="flex items-center gap-2 bg-bg-card border border-border rounded-lg px-3.5 py-2.5">
<span className="text-text-muted">🔍</span>
<aside className="fixed bottom-0 left-0 top-[56px] z-40 flex w-[260px] flex-col gap-2 overflow-y-auto border-r border-border bg-surface-raised px-4 py-5 shadow-card">
<div className="rounded-md border border-border bg-surface-raised p-3 shadow-card">
<div className="mb-2 flex items-center justify-between">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-text-muted">
Active Terminal
</div>
<div className="mt-1 font-serif text-lg font-bold text-brand-navy">ATLAS Desk</div>
</div>
<Sparkles className="h-4 w-4 text-brand-gold" />
</div>
<div className="flex items-center gap-2 rounded-md border border-border bg-surface-sunken px-3 py-2">
<Search className="h-4 w-4 text-text-muted" />
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="Search ticker..."
className="bg-transparent border-none text-text-primary outline-none w-full"
className="w-full border-none bg-transparent text-sm text-text-primary outline-none"
/>
</div>
<div className="mt-2 px-3.5 py-1.5 bg-bg-card rounded-md flex items-center justify-between">
<span className="text-text-muted text-sm">Active:</span>
<span className="text-accent-green font-mono font-bold">{ticker}</span>
<div className="mt-3 flex items-center justify-between rounded-md bg-brand-navy px-3 py-2 text-white">
<span className="text-xs uppercase tracking-[0.12em] text-white/70">Ticker</span>
<span className="font-mono text-sm font-bold text-brand-gold">{ticker}</span>
</div>
</div>
{/* Navigation */}
<nav className="flex flex-col gap-1 mt-3">
<nav className="mt-3 flex flex-col gap-1">
{NAV_ITEMS.map((item) => {
const active = pathname === item.href;
const Icon = item.icon;
return (
<Link key={item.href} href={item.href} className="no-underline">
<div
className={`flex items-center gap-2.5 px-3.5 py-2.5 rounded-lg text-base transition-all duration-150 border-l-2 ${
className={`flex items-center gap-3 rounded-md border-l-4 px-3.5 py-2.5 text-sm transition-all duration-150 ${
active
? "bg-accent-green/10 text-accent-green font-semibold border-accent-green"
: "text-text-secondary font-normal border-transparent hover:bg-bg-card"
? "border-brand-gold bg-surface-sunken font-semibold text-brand-navy"
: "border-transparent text-text-secondary hover:bg-surface-sunken hover:text-brand-navy"
}`}
>
<span>{item.icon}</span> {item.label}
<Icon className={`h-[18px] w-[18px] ${active ? "text-brand-gold" : "text-brand-navy"}`} />
{item.label}
</div>
</Link>
);
})}
<div className="border-t border-border my-2" />
<div className="my-2 border-t border-border" />
<Link href="/settings" className="no-underline">
<div
className={`flex items-center gap-2.5 px-3.5 py-2.5 rounded-lg text-base transition-all duration-150 border-l-2 ${
className={`flex items-center gap-3 rounded-md border-l-4 px-3.5 py-2.5 text-sm transition-all duration-150 ${
pathname === "/settings"
? "bg-accent-green/10 text-accent-green font-semibold border-accent-green"
: "text-text-secondary font-normal border-transparent hover:bg-bg-card"
? "border-brand-gold bg-surface-sunken font-semibold text-brand-navy"
: "border-transparent text-text-secondary hover:bg-surface-sunken hover:text-brand-navy"
}`}
>
<span></span> Settings
<Settings className={`h-[18px] w-[18px] ${pathname === "/settings" ? "text-brand-gold" : "text-brand-navy"}`} />
Settings
</div>
</Link>
</nav>
@@ -1,5 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { Dot } from "lucide-react";
interface IndexData {
label: string;
@@ -41,17 +42,21 @@ export function TickerBar() {
}, []);
return (
<header className="fixed top-0 left-0 right-0 z-50 h-[52px] bg-bg-primary border-b border-border flex items-center px-5 gap-4">
<div className="font-mono font-bold text-accent-green text-lg mr-5">
ATLAS<span className="text-text-secondary font-normal"> TERMINAL</span>
<header className="fixed left-0 right-0 top-0 z-50 flex h-[56px] items-center gap-4 border-b border-border bg-surface-raised px-5 shadow-card">
<div className="mr-4 flex items-baseline gap-2">
<span className="font-serif text-xl font-bold text-brand-navy">ATLAS</span>
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-brand-gold">
Morgan Terminal
</span>
</div>
<div className="flex gap-5 overflow-hidden">
{data.map((idx) => (
<div key={idx.label} className="flex items-center gap-2 text-sm font-mono">
<div key={idx.label} className="flex items-center gap-1.5 text-sm font-mono">
<span className="text-text-muted">{idx.label}</span>
<span className="text-text-primary font-semibold">{idx.price}</span>
{idx.change !== "—" && (
<span className={idx.positive ? "text-accent-green" : "text-accent-red"}>
<span className={`inline-flex items-center ${idx.positive ? "text-fin-positive" : "text-fin-negative"}`}>
<Dot className="-mx-1 h-4 w-4" />
{idx.change}
</span>
)}
@@ -0,0 +1,24 @@
import type { ReactNode } from "react";
interface BadgeProps {
variant: "buy" | "sell" | "hold" | "neutral";
children?: ReactNode;
className?: string;
}
const badgeClasses: Record<BadgeProps["variant"], string> = {
buy: "bg-fin-positive/10 text-fin-positive border-fin-positive/20",
sell: "bg-fin-negative/10 text-fin-negative border-fin-negative/20",
hold: "bg-brand-gold/15 text-brand-navy border-brand-gold/40",
neutral: "bg-surface-sunken text-text-secondary border-border",
};
export function Badge({ variant, children, className = "" }: BadgeProps) {
return (
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-bold uppercase tracking-[0.08em] ${badgeClasses[variant]} ${className}`}
>
{children ?? variant}
</span>
);
}
@@ -0,0 +1,34 @@
import type { ReactNode } from "react";
interface CardProps {
title?: ReactNode;
subtitle?: ReactNode;
action?: ReactNode;
children: ReactNode;
className?: string;
contentClassName?: string;
}
export function Card({
title,
subtitle,
action,
children,
className = "",
contentClassName = "",
}: CardProps) {
return (
<section className={`atlas-card ${className}`}>
{(title || subtitle || action) && (
<header className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div>
{title && <h3 className="font-serif text-lg font-bold text-brand-navy">{title}</h3>}
{subtitle && <p className="mt-1 text-sm text-text-secondary">{subtitle}</p>}
</div>
{action}
</header>
)}
<div className={`p-5 ${contentClassName}`}>{children}</div>
</section>
);
}
@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
interface ChartContainerProps {
title?: string;
subtitle?: string;
children: ReactNode;
className?: string;
}
export function ChartContainer({
title,
subtitle,
children,
className = "",
}: ChartContainerProps) {
return (
<section className={`atlas-card p-5 ${className}`}>
{(title || subtitle) && (
<div className="mb-4">
{title && <h3 className="font-serif text-lg font-bold text-brand-navy">{title}</h3>}
{subtitle && <p className="mt-1 text-sm text-text-secondary">{subtitle}</p>}
</div>
)}
{children}
</section>
);
}
@@ -0,0 +1,73 @@
import type { ReactNode } from "react";
interface Column<T> {
key: string;
header: ReactNode;
align?: "left" | "right" | "center";
render: (row: T) => ReactNode;
}
interface DataTableProps<T> {
columns: Column<T>[];
rows: T[];
rowKey: (row: T, index: number) => string;
emptyMessage?: string;
className?: string;
}
const alignClass = {
left: "text-left",
right: "text-right",
center: "text-center",
} as const;
export function DataTable<T>({
columns,
rows,
rowKey,
emptyMessage = "No rows to display.",
className = "",
}: DataTableProps<T>) {
return (
<div className={`atlas-table-shell ${className}`}>
<div className="overflow-x-auto">
<table className="min-w-full border-collapse text-sm">
<thead className="bg-surface-sunken">
<tr>
{columns.map((column) => (
<th
key={column.key}
className={`border-b border-border-strong px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.12em] text-brand-navy ${alignClass[column.align ?? "left"]}`}
>
{column.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-4 py-6 text-center text-text-muted">
{emptyMessage}
</td>
</tr>
) : (
rows.map((row, index) => (
<tr key={rowKey(row, index)} className="border-b border-border last:border-b-0">
{columns.map((column) => (
<td
key={column.key}
className={`px-4 py-3 align-top text-text-primary ${alignClass[column.align ?? "left"]}`}
>
{column.render(row)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,48 @@
"use client";
import type { ReactNode } from "react";
import { AlertTriangle, Info, TriangleAlert } from "lucide-react";
export type BannerVariant = "error" | "info" | "warning";
interface ErrorBannerProps {
message: string | null | undefined;
variant?: BannerVariant;
className?: string;
icon?: ReactNode;
}
/**
* Unified info / warning / error banner used across ATLAS pages.
*
* Design contract (preserved through the upcoming Morgan Stanley Blue redesign):
* - "info" / "warning": soft yellow border+background, never red.
* Used when data is partially missing or a dependency is degraded.
* - "error": red. Reserved for true failures the user must act on.
*
* If `message` is falsy, renders nothing (drop-in safe).
*/
export function ErrorBanner({ message, variant = "info", className = "", icon }: ErrorBannerProps) {
if (!message) return null;
const base = "rounded-md border px-4 py-3 text-sm shadow-card";
const toneMap: Record<BannerVariant, string> = {
info: "border-brand-gold/40 bg-brand-gold/10 text-brand-navy",
warning: "border-fin-warning/40 bg-fin-warning/10 text-fin-warning",
error: "border-fin-negative/40 bg-fin-negative/10 text-fin-negative",
};
const defaultIcon =
variant === "error" ? <AlertTriangle className="h-4 w-4" /> :
variant === "warning" ? <TriangleAlert className="h-4 w-4" /> :
<Info className="h-4 w-4" />;
return (
<div
className={`${base} ${toneMap[variant]} ${className}`}
role={variant === "error" ? "alert" : "status"}
>
<div className="flex items-start gap-2.5">
<span className="mt-0.5 shrink-0">{icon ?? defaultIcon}</span>
<span>{message}</span>
</div>
</div>
);
}
@@ -0,0 +1,20 @@
"use client";
import { LoaderCircle } from "lucide-react";
interface LoadingPulseProps {
label?: string;
height?: string;
className?: string;
}
export function LoadingPulse({ label = "Loading…", height = "h-64", className = "" }: LoadingPulseProps) {
return (
<div className={`flex items-center justify-center ${height} ${className}`}>
<div className="inline-flex items-center gap-2 rounded-full border border-border bg-surface-raised px-4 py-2 text-sm text-brand-navy shadow-card">
<LoaderCircle className="h-4 w-4 animate-spin text-brand-blue" />
<span className="font-mono">{label}</span>
</div>
</div>
);
}
@@ -0,0 +1,20 @@
interface MetricChangeProps {
value: number | null;
suffix?: string;
className?: string;
}
export function MetricChange({ value, suffix = "%", className = "" }: MetricChangeProps) {
if (value == null || Number.isNaN(value)) {
return <span className={`font-mono text-xs text-text-muted ${className}`}>N/A</span>;
}
const tone = value > 0 ? "text-fin-positive" : value < 0 ? "text-fin-negative" : "text-fin-neutral";
return (
<span className={`font-mono text-xs font-semibold ${tone} ${className}`}>
{value > 0 ? "+" : ""}
{value.toFixed(1)}
{suffix}
</span>
);
}
@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
interface SectionHeadingProps {
level?: 1 | 2 | 3;
children: ReactNode;
className?: string;
}
export function SectionHeading({
level = 2,
children,
className = "",
}: SectionHeadingProps) {
if (level === 1) {
return <h1 className={`atlas-page-title ${className}`}>{children}</h1>;
}
if (level === 3) {
return (
<h3 className={`text-xs font-semibold uppercase tracking-[0.18em] text-text-secondary ${className}`}>
{children}
</h3>
);
}
return <h2 className={`atlas-section-title ${className}`}>{children}</h2>;
}
@@ -0,0 +1,32 @@
import type { ReactNode } from "react";
interface StatCardProps {
label: string;
value: ReactNode;
detail?: ReactNode;
tone?: "default" | "positive" | "negative" | "accent";
className?: string;
}
const toneClass: Record<NonNullable<StatCardProps["tone"]>, string> = {
default: "text-text-primary",
positive: "text-fin-positive",
negative: "text-fin-negative",
accent: "text-brand-blue",
};
export function StatCard({
label,
value,
detail,
tone = "default",
className = "",
}: StatCardProps) {
return (
<div className={`atlas-card p-4 ${className}`}>
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-text-muted">{label}</div>
<div className={`mt-2 font-mono text-[22px] font-bold leading-tight ${toneClass[tone]}`}>{value}</div>
{detail && <div className="mt-2 text-xs text-text-secondary">{detail}</div>}
</div>
);
}
@@ -0,0 +1,37 @@
import type { ReactNode } from "react";
interface TabItem<T extends string> {
key: T;
label: ReactNode;
}
interface TabsProps<T extends string> {
items: TabItem<T>[];
value: T;
onChange: (value: T) => void;
className?: string;
}
export function Tabs<T extends string>({ items, value, onChange, className = "" }: TabsProps<T>) {
return (
<div className={`flex gap-1 rounded-md border border-border bg-surface-sunken p-1 ${className}`}>
{items.map((item) => {
const active = item.key === value;
return (
<button
key={item.key}
type="button"
onClick={() => onChange(item.key)}
className={`flex-1 rounded-md px-3 py-2 text-sm font-semibold transition-colors ${
active
? "bg-brand-navy text-text-inverse shadow-sm"
: "text-text-secondary hover:bg-surface-raised hover:text-brand-navy"
}`}
>
{item.label}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,31 @@
# ATLAS Morgan Stanley Design System
## Core Palette
- `surface.canvas`: `#FAFAFA`
- `surface.raised`: `#FFFFFF`
- `surface.sunken`: `#F1F3F6`
- `brand.navy`: `#1B2A4A`
- `brand.blue`: `#2E5B9A`
- `brand.gold`: `#C4A35A`
- `fin.positive`: `#2D8B5E`
- `fin.negative`: `#C0392B`
- `fin.warning`: `#D9822B`
- `text.primary`: `#1A1A2E`
- `text.secondary`: `#4A5568`
- `text.muted`: `#6B7B8D`
## Typography
- Headings: `Source Serif 4`
- Body: `Inter`
- Numbers, tickers, dense tables: `JetBrains Mono` with tabular numerals
## Component Rules
- Page titles use serif typography and a bottom hairline.
- Cards use `surface.raised`, `border.DEFAULT`, and `shadow-card`.
- Tabs use navy active states and sunken inactive rails.
- Positive/negative metrics use `fin.positive` and `fin.negative`; warnings use gold or warning amber, not red.
- Charts should import colors from `src/app/lib/chart-theme.ts` instead of hardcoding hex values.
## Migration Notes
- Legacy `bg.*` and `accent.*` Tailwind tokens still exist as compatibility aliases.
- `/report` remains the visual source of truth for print layout; the rest of the app is converging toward that tone.
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Info } from "lucide-react";
import {
FilingsViewer,
type FilingsViewerHandle,
@@ -330,8 +331,9 @@ export default function FilingsPage() {
</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}
<div className="mt-3 flex items-start gap-2 rounded-md border border-accent-yellow/30 bg-accent-yellow/10 px-4 py-2.5 text-sm text-accent-yellow">
<Info className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
{loading && (
+158 -111
View File
@@ -2,6 +2,25 @@
@tailwind components;
@tailwind utilities;
:root {
--surface-canvas: #fafafa;
--surface-raised: #ffffff;
--surface-sunken: #f1f3f6;
--surface-overlay: #ffffff;
--brand-navy: #1b2a4a;
--brand-blue: #2e5b9a;
--brand-gold: #c4a35a;
--text-primary: #1a1a2e;
--text-secondary: #4a5568;
--text-muted: #6b7b8d;
--border-color: #e8ecf0;
--border-strong: #cbd5df;
--border-subtle: #f1f3f6;
--fin-positive: #2d8b5e;
--fin-negative: #c0392b;
--fin-warning: #d9822b;
}
* {
box-sizing: border-box;
margin: 0;
@@ -13,40 +32,53 @@ html {
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: var(--surface-canvas);
}
body {
background: #0A0A0F;
color: #F3F4F6;
font-family: "Inter", system-ui, sans-serif;
background: radial-gradient(circle at top left, rgba(196, 163, 90, 0.08), transparent 28%),
linear-gradient(180deg, #fcfcfd 0%, var(--surface-canvas) 100%);
color: var(--text-primary);
font-family: var(--font-sans), system-ui, sans-serif;
}
:root {
--text-primary: #f3f4f6;
--text-secondary: #9ca3af;
--border-color: #2a2a3a;
--bg-card: #1a1a26;
h1,
h2 {
font-family: var(--font-serif), Georgia, serif;
}
::selection {
background: rgba(46, 91, 154, 0.18);
color: var(--brand-navy);
}
a {
color: inherit;
}
.font-mono,
.tabular-nums {
font-variant-numeric: tabular-nums;
}
th,
thead {
letter-spacing: 0.08em;
}
.sec-viewer-container {
font-size: 0.875rem;
line-height: 1.65;
color: var(--text-primary);
background: transparent;
background: var(--surface-raised);
}
.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;
color: var(--brand-blue) !important;
text-decoration: underline;
}
@@ -54,6 +86,7 @@ body {
border-collapse: collapse;
width: max-content;
max-width: none;
background: var(--surface-raised);
}
.sec-viewer-container th,
@@ -61,6 +94,7 @@ body {
border: 1px solid var(--border-color) !important;
padding: 0.35rem 0.5rem !important;
vertical-align: top;
color: var(--text-primary) !important;
}
.sec-viewer-container img {
@@ -69,144 +103,128 @@ body {
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #0A0A0F;
}
::-webkit-scrollbar-thumb {
background: #2A2A3A;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #3A3A4A;
width: 8px;
height: 8px;
}
input::placeholder {
color: #6B7280;
::-webkit-scrollbar-track {
background: var(--surface-sunken);
}
::-webkit-scrollbar-thumb {
background: #c5ced8;
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: #9ba9bb;
}
input::placeholder,
textarea::placeholder {
color: var(--text-muted);
}
.action-btn {
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
cursor: pointer;
color: var(--brand-navy);
transition: background 0.15s ease, border-color 0.15s ease;
}
.action-btn:hover {
background: var(--surface-sunken);
border-color: var(--border-color);
}
.edit-input {
width: 100%;
max-width: 120px;
background: #0A0A0F;
border: 1px solid #4DA6FF;
border-radius: 6px;
background: var(--surface-raised);
border: 1px solid var(--brand-blue);
border-radius: 8px;
padding: 6px 10px;
color: #F3F4F6;
font-family: "JetBrains Mono", monospace;
color: var(--text-primary);
font-family: var(--font-mono), monospace;
font-size: 13px;
outline: none;
}
.delete-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
inset: 0;
background: rgba(27, 42, 74, 0.24);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.portfolio-header {
.portfolio-header,
.heatmap-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.currency-toggle {
display: flex;
gap: 4px;
background: #1A1A26;
border: 1px solid #2A2A3A;
border-radius: 8px;
padding: 3px;
}
.currency-btn {
padding: 6px 14px;
border: none;
border-radius: 6px;
background: transparent;
color: #9CA3AF;
font-family: "JetBrains Mono", monospace;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.currency-btn:hover {
color: #F3F4F6;
background: #252536;
}
.currency-btn.active {
background: #00D4AA;
color: #0A0A0F;
}
.heatmap-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.currency-toggle,
.index-toggle {
display: flex;
gap: 4px;
background: #1A1A26;
border: 1px solid #2A2A3A;
border-radius: 8px;
background: var(--surface-sunken);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 3px;
}
.currency-btn,
.index-btn {
padding: 6px 16px;
padding: 6px 14px;
border: none;
border-radius: 6px;
border-radius: 8px;
background: transparent;
color: #9CA3AF;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
font-family: var(--font-mono), monospace;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
transition: all 0.15s ease;
white-space: nowrap;
}
.index-btn {
padding-inline: 16px;
font-family: var(--font-sans), sans-serif;
font-size: 13px;
}
.currency-btn:hover,
.index-btn:hover {
color: #F3F4F6;
background: #252536;
color: var(--brand-navy);
background: rgba(27, 42, 74, 0.06);
}
.currency-btn.active,
.index-btn.active {
background: #00D4AA;
color: #0A0A0F;
background: var(--brand-navy);
color: #ffffff;
}
.treemap-container {
display: flex;
flex-wrap: wrap;
gap: 2px;
border-radius: 8px;
border-radius: 12px;
overflow: hidden;
min-height: 400px;
background: #0A0A0F;
background: var(--surface-sunken);
border: 1px solid var(--border-color);
}
.treemap-sector {
@@ -218,15 +236,15 @@ input::placeholder {
.treemap-sector-label {
font-size: 10px;
font-weight: 600;
color: #9CA3AF;
font-weight: 700;
color: var(--brand-navy);
padding: 4px 6px;
background: rgba(0, 0, 0, 0.3);
background: rgba(255, 255, 255, 0.84);
position: absolute;
top: 0;
left: 0;
z-index: 1;
border-radius: 4px 0 4px 0;
border-radius: 6px 0 6px 0;
pointer-events: none;
}
@@ -246,34 +264,37 @@ input::placeholder {
min-height: 50px;
padding: 4px;
cursor: pointer;
transition: filter 0.15s, transform 0.1s;
transition: filter 0.15s ease, transform 0.1s ease;
border-radius: 2px;
}
.treemap-cell:hover {
filter: brightness(1.15);
transform: scale(1.02);
filter: brightness(1.04);
transform: scale(1.01);
z-index: 2;
}
.treemap-ticker,
.treemap-change,
.treemap-subticker {
font-family: var(--font-mono), monospace;
}
.treemap-ticker {
font-family: "JetBrains Mono", monospace;
font-size: 11px;
font-weight: 700;
color: #F3F4F6;
color: #ffffff;
}
.treemap-change {
font-family: "JetBrains Mono", monospace;
font-size: 10px;
font-weight: 600;
color: #F3F4F6;
color: rgba(255, 255, 255, 0.96);
}
.treemap-subticker {
font-family: "JetBrains Mono", monospace;
font-size: 9px;
color: #9CA3AF;
color: rgba(255, 255, 255, 0.82);
}
.heatmap-loading {
@@ -281,6 +302,32 @@ input::placeholder {
align-items: center;
justify-content: center;
min-height: 400px;
color: #9CA3AF;
color: var(--text-muted);
font-size: 14px;
}
@layer components {
.atlas-page {
@apply space-y-6;
}
.atlas-page-title {
@apply border-b border-border pb-3 font-serif text-[32px] font-bold leading-tight text-brand-navy;
}
.atlas-page-subtitle {
@apply mt-2 text-sm text-text-secondary;
}
.atlas-card {
@apply rounded-md border border-border bg-surface-raised shadow-card;
}
.atlas-table-shell {
@apply overflow-hidden rounded-md border border-border bg-surface-raised shadow-card;
}
.atlas-section-title {
@apply border-b border-border pb-2 font-serif text-[22px] font-bold leading-snug text-brand-navy;
}
}
+23 -8
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { Inter, JetBrains_Mono, Source_Serif_4 } from "next/font/google";
import "./globals.css";
import { AppShell } from "./components/app-shell";
@@ -7,17 +8,31 @@ export const metadata: Metadata = {
description: "Advanced Trading & Liquidity Analysis System",
};
const inter = Inter({
subsets: ["latin"],
variable: "--font-sans",
display: "swap",
});
const sourceSerif = Source_Serif_4({
subsets: ["latin"],
variable: "--font-serif",
display: "swap",
});
const jetBrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-mono",
display: "swap",
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body className="min-h-screen antialiased" suppressHydrationWarning>
<body
className={`${inter.variable} ${sourceSerif.variable} ${jetBrainsMono.variable} min-h-screen antialiased`}
suppressHydrationWarning
>
<AppShell>{children}</AppShell>
</body>
</html>
@@ -0,0 +1,51 @@
export const chartPalette = {
navy: "#1B2A4A",
blue: "#2E5B9A",
gold: "#C4A35A",
green: "#2D8B5E",
red: "#C0392B",
neutral: "#6B7B8D",
neutralLight: "#CBD5DF",
grid: "#E8ECF0",
canvas: "#FFFFFF",
sunken: "#F1F3F6",
text: "#1A1A2E",
textMuted: "#6B7B8D",
} as const;
export const categoricalChartColors = [
chartPalette.navy,
chartPalette.blue,
chartPalette.gold,
chartPalette.green,
chartPalette.red,
chartPalette.neutral,
] as const;
export const rechartsTheme = {
gridStroke: chartPalette.grid,
axisStroke: chartPalette.neutralLight,
tickFill: chartPalette.textMuted,
tooltip: {
backgroundColor: chartPalette.canvas,
border: `1px solid ${chartPalette.grid}`,
color: chartPalette.text,
},
} as const;
export const lightweightTheme = {
layout: {
background: { color: chartPalette.canvas },
textColor: chartPalette.textMuted,
},
grid: {
vertLines: { color: chartPalette.grid },
horzLines: { color: chartPalette.grid },
},
timeScale: {
borderColor: chartPalette.neutralLight,
},
rightPriceScale: {
borderColor: chartPalette.neutralLight,
},
} as const;
@@ -0,0 +1,187 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
/**
* useApi A lightweight fetch hook for ATLAS Terminal.
*
* Features:
* - Hydration-safe (doesn't fire on the server).
* - Automatic AbortController management across deps changes / unmount.
* - Module-scoped in-flight request dedup: same URL + same method share a promise.
* - Parses `{ detail }` and `{ error }` backend error shapes.
* - Conditional fetch via `opts.enabled` or passing `url = null`.
* - Manual `refetch()` escape hatch.
*
* We intentionally do NOT cache responses. A future SWR migration can layer caching
* on top without changing the hook surface.
*/
type Json = unknown;
interface InFlightEntry {
promise: Promise<Response>;
controller: AbortController;
refCount: number;
}
const inflight = new Map<string, InFlightEntry>();
function makeKey(url: string, method: string, body?: string): string {
return `${method.toUpperCase()} ${url}${body ? ` :: ${body}` : ""}`;
}
async function sharedFetch(
url: string,
init: RequestInit,
externalSignal: AbortSignal,
): Promise<Response> {
const method = (init.method || "GET").toUpperCase();
const bodyKey = typeof init.body === "string" ? init.body : undefined;
const key = makeKey(url, method, bodyKey);
let entry = inflight.get(key);
if (!entry) {
const controller = new AbortController();
const promise = fetch(url, { ...init, signal: controller.signal }).finally(() => {
inflight.delete(key);
});
entry = { promise, controller, refCount: 0 };
inflight.set(key, entry);
}
entry.refCount += 1;
const onAbort = () => {
if (!entry) return;
entry.refCount -= 1;
// Only abort the underlying request if no other caller still needs it.
if (entry.refCount <= 0) {
entry.controller.abort();
inflight.delete(key);
}
};
if (externalSignal.aborted) {
onAbort();
throw new DOMException("Aborted", "AbortError");
}
externalSignal.addEventListener("abort", onAbort, { once: true });
try {
// Clone so multiple consumers can each call .json() on the response.
const resp = await entry.promise;
return resp.clone();
} finally {
externalSignal.removeEventListener("abort", onAbort);
}
}
async function parseError(resp: Response): Promise<string> {
try {
const data = await resp.clone().json();
if (data && typeof data === "object") {
const obj = data as Record<string, unknown>;
if (typeof obj.detail === "string") return obj.detail;
if (typeof obj.error === "string") return obj.error;
if (typeof obj.message === "string") return obj.message;
}
} catch {
/* fall through */
}
try {
const txt = await resp.text();
if (txt) return txt.slice(0, 240);
} catch {
/* ignore */
}
return `HTTP ${resp.status} ${resp.statusText || ""}`.trim();
}
export interface UseApiOptions {
enabled?: boolean;
method?: "GET" | "POST";
body?: Json;
headers?: Record<string, string>;
}
export interface UseApiResult<T> {
data: T | null;
loading: boolean;
error: string | null;
refetch: () => void;
}
export function useApi<T = unknown>(
url: string | null,
opts: UseApiOptions = {},
): UseApiResult<T> {
const { enabled = true, method = "GET", body, headers } = opts;
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState<boolean>(Boolean(url) && enabled !== false);
const [error, setError] = useState<string | null>(null);
const [tick, setTick] = useState(0);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const bodyStr = body !== undefined ? JSON.stringify(body) : undefined;
useEffect(() => {
if (!url || enabled === false) {
setLoading(false);
return;
}
const controller = new AbortController();
setLoading(true);
setError(null);
const init: RequestInit = {
method,
headers: {
...(bodyStr ? { "Content-Type": "application/json" } : {}),
...(headers || {}),
},
body: bodyStr,
};
sharedFetch(url, init, controller.signal)
.then(async (resp) => {
if (!resp.ok) {
const msg = await parseError(resp);
throw new Error(msg);
}
const ct = resp.headers.get("content-type") || "";
const payload = ct.includes("application/json") ? await resp.json() : await resp.text();
if (!mountedRef.current || controller.signal.aborted) return;
setData(payload as T);
setError(null);
})
.catch((err: unknown) => {
if ((err as Error)?.name === "AbortError") return;
if (!mountedRef.current) return;
setError((err as Error)?.message || "Request failed");
setData(null);
})
.finally(() => {
if (!mountedRef.current || controller.signal.aborted) return;
setLoading(false);
});
return () => {
controller.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url, method, bodyStr, enabled, tick]);
const refetch = useCallback(() => {
setTick((t) => t + 1);
}, []);
return { data, loading, error, refetch };
}
+136 -44
View File
@@ -9,6 +9,8 @@ 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";
import { ErrorBanner } from "../components/ui/ErrorBanner";
import { LoadingPulse } from "../components/ui/LoadingPulse";
const FRED_PRESETS = [
{ id: "UNRATE", label: "US Unemployment %" },
@@ -40,6 +42,30 @@ interface SubfactorData {
categories: Record<string, { score: number; indicators: SubfactorIndicator[] }>;
}
const MACRO_ERROR_MESSAGES: Record<string, string> = {
upstream_timeout: "Upstream macro source timed out. Try again in a moment.",
fred_empty: "FRED returned no usable series for this view.",
fx_empty: "FX history could not be loaded for this pair.",
invalid_pair: "This yield/FX pair is not supported.",
missing_peer: "Peer bond series is unavailable for this pair.",
};
function formatMacroError(error: unknown, fallback: string): string {
if (typeof error !== "string" || !error.trim()) {
return fallback;
}
if (error in MACRO_ERROR_MESSAGES) {
return MACRO_ERROR_MESSAGES[error];
}
if (error.startsWith("HTTP_")) {
return `Request failed (${error.replace("HTTP_", "HTTP ")}).`;
}
return error.replaceAll("_", " ");
}
export default function MacroPage() {
const [series, setSeries] = useState("UNRATE");
const [rows, setRows] = useState<FredPoint[]>([]);
@@ -55,10 +81,12 @@ export default function MacroPage() {
const [subfactors, setSubfactors] = useState<SubfactorData | null>(null);
const [quadPoints, setQuadPoints] = useState<QuadrantPoint[]>([]);
const [quadLoading, setQuadLoading] = useState(true);
const [quadErr, setQuadErr] = useState<string | null>(null);
const [yieldPair, setYieldPair] = useState<"usdjpy" | "eurusd" | "usdkrw">("usdjpy");
const [yieldRows, setYieldRows] = useState<YieldFxRow[]>([]);
const [yieldLoading, setYieldLoading] = useState(true);
const [yieldErr, setYieldErr] = useState<string | null>(null);
const [roroZ, setRoroZ] = useState<number | null>(null);
@@ -66,43 +94,60 @@ export default function MacroPage() {
const [copperGold, setCopperGold] = useState<
{ date: string; ratio: number; ratio_ma20?: number }[]
>([]);
const [smartLoading, setSmartLoading] = useState(true);
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)))))
const controller = new AbortController();
fetch(`/api/macro/fred/${encodeURIComponent(series)}`, { signal: controller.signal })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP_${r.status}`))))
.then((j) => {
setRows(Array.isArray(j.data) ? j.data : []);
setLoading(false);
})
.catch(() => {
setErr("Failed to load FRED series.");
.catch((error: unknown) => {
if (controller.signal.aborted) return;
setErr(formatMacroError(error instanceof Error ? error.message : error, "Failed to load FRED series."));
setLoading(false);
});
return () => controller.abort();
}, [series]);
useEffect(() => {
fetch("/api/macro/snapshot")
.then((r) => (r.ok ? r.json() : null))
setSnapErr(null);
const controller = new AbortController();
fetch("/api/macro/snapshot", { signal: controller.signal })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP_${r.status}`))))
.then((j) => {
if (!j || typeof j !== "object") {
setSnap(null);
setSnapErr("Macro snapshot returned an empty payload.");
return;
}
if (j.error) setSnapErr(String(j.error));
const cycleHeatmap = Array.isArray(j.cycle_heatmap) ? j.cycle_heatmap : [];
const countryHeatmap = Array.isArray(j.country_heatmap) ? j.country_heatmap : [];
const assetValuation = Array.isArray(j.asset_valuation) ? j.asset_valuation : [];
const hasSnapshotData = cycleHeatmap.length > 0 || countryHeatmap.length > 0 || assetValuation.length > 0;
if (j.error) setSnapErr(formatMacroError(j.error, "Failed to load macro snapshot."));
else if (!hasSnapshotData) setSnapErr("Macro snapshot returned no usable data.");
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 : [],
cycle_heatmap: cycleHeatmap,
country_heatmap: countryHeatmap,
asset_valuation: assetValuation,
});
})
.catch(() => setSnap(null));
.catch((error: unknown) => {
if (controller.signal.aborted) return;
setSnap(null);
setSnapErr(formatMacroError(error instanceof Error ? error.message : error, "Failed to load macro snapshot."));
});
return () => controller.abort();
}, []);
useEffect(() => {
@@ -123,55 +168,100 @@ export default function MacroPage() {
useEffect(() => {
setQuadErr(null);
fetch("/api/macro/quadrant")
.then((r) => (r.ok ? r.json() : null))
setQuadLoading(true);
const controller = new AbortController();
fetch("/api/macro/quadrant", { signal: controller.signal })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP_${r.status}`))))
.then((j) => {
if (!j || typeof j !== "object") {
setQuadPoints([]);
setQuadErr("Macro quadrant returned an empty payload.");
return;
}
if (j.error) setQuadErr(String(j.error));
setQuadPoints(Array.isArray(j.points) ? j.points : []);
const points = Array.isArray(j.points) ? j.points : [];
if (j.error) setQuadErr(formatMacroError(j.error, "Failed to load macro quadrant."));
else if (!points.length) setQuadErr("No quadrant points were produced from current macro sources.");
setQuadPoints(points);
})
.catch(() => setQuadPoints([]));
.catch((error: unknown) => {
if (controller.signal.aborted) return;
setQuadPoints([]);
setQuadErr(formatMacroError(error instanceof Error ? error.message : error, "Failed to load macro quadrant."));
})
.finally(() => {
if (!controller.signal.aborted) {
setQuadLoading(false);
}
});
return () => controller.abort();
}, []);
useEffect(() => {
setYieldErr(null);
fetch(`/api/macro/yield-fx?pair=${encodeURIComponent(yieldPair)}`)
.then((r) => (r.ok ? r.json() : null))
setYieldLoading(true);
const controller = new AbortController();
fetch(`/api/macro/yield-fx?pair=${encodeURIComponent(yieldPair)}`, { signal: controller.signal })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP_${r.status}`))))
.then((j) => {
if (!j || typeof j !== "object") {
setYieldRows([]);
setYieldErr("Yield/FX returned an empty payload.");
return;
}
if (j.error) setYieldErr(String(j.error));
setYieldRows(Array.isArray(j.series) ? j.series : []);
const seriesRows = Array.isArray(j.series) ? j.series : [];
if (j.error) setYieldErr(formatMacroError(j.error, "Failed to load yield/FX data."));
else if (!seriesRows.length) setYieldErr("Yield/FX returned no usable observations.");
setYieldRows(seriesRows);
})
.catch(() => setYieldRows([]));
.catch((error: unknown) => {
if (controller.signal.aborted) return;
setYieldRows([]);
setYieldErr(formatMacroError(error instanceof Error ? error.message : error, "Failed to load yield/FX data."));
})
.finally(() => {
if (!controller.signal.aborted) {
setYieldLoading(false);
}
});
return () => controller.abort();
}, [yieldPair]);
useEffect(() => {
setSmartErr(null);
fetch("/api/macro/smart-money")
.then((r) => (r.ok ? r.json() : null))
setSmartLoading(true);
const controller = new AbortController();
fetch("/api/macro/smart-money", { signal: controller.signal })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP_${r.status}`))))
.then((j) => {
if (!j || typeof j !== "object") {
setRoroZ(null);
setRoroLabel(null);
setCopperGold([]);
setSmartErr("Smart money returned an empty payload.");
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 : []);
const nextRoroZ = typeof j.roro_z === "number" ? j.roro_z : null;
const nextRoroLabel = j.roro_label != null ? String(j.roro_label) : null;
const nextCopperGold = Array.isArray(j.copper_gold) ? j.copper_gold : [];
if (j.error) setSmartErr(formatMacroError(j.error, "Failed to load smart money data."));
else if (nextRoroZ == null && !nextCopperGold.length) setSmartErr("Smart money inputs returned no usable data.");
setRoroZ(nextRoroZ);
setRoroLabel(nextRoroLabel);
setCopperGold(nextCopperGold);
})
.catch(() => {
.catch((error: unknown) => {
if (controller.signal.aborted) return;
setRoroZ(null);
setRoroLabel(null);
setCopperGold([]);
setSmartErr(formatMacroError(error instanceof Error ? error.message : error, "Failed to load smart money data."));
})
.finally(() => {
if (!controller.signal.aborted) {
setSmartLoading(false);
}
});
return () => controller.abort();
}, []);
const tabs: { key: MacroTab; label: string }[] = [
@@ -198,10 +288,12 @@ export default function MacroPage() {
<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>
<ErrorBanner message={quadErr} variant="warning" className="mb-3" />
{quadLoading ? (
<LoadingPulse label="Loading macro quadrant…" height="h-[320px]" />
) : (
<GlobalMacroQuadrantChart points={quadPoints} />
)}
<GlobalMacroQuadrantChart points={quadPoints} />
</div>
<div className="lg:col-span-6 bg-bg-secondary/30 border border-border/60 rounded-lg p-4">
@@ -224,19 +316,23 @@ export default function MacroPage() {
))}
</div>
</div>
{yieldErr && (
<div className="text-accent-yellow text-xs font-mono mb-2">Warning: {yieldErr}</div>
<ErrorBanner message={yieldErr} variant="warning" className="mb-3" />
{yieldLoading ? (
<LoadingPulse label="Loading yield/FX series…" height="h-[280px]" />
) : (
<YieldFxDualAxisChart pair={yieldPair} rows={yieldRows} />
)}
<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>
<ErrorBanner message={smartErr} variant="warning" className="mb-3" />
{smartLoading ? (
<LoadingPulse label="Loading smart money indicators…" height="h-[260px]" />
) : (
<SmartMoneyPanel roroZ={roroZ} roroLabel={roroLabel} copperGold={copperGold} />
)}
<SmartMoneyPanel roroZ={roroZ} roroLabel={roroLabel} copperGold={copperGold} />
</div>
</div>
</div>
@@ -287,9 +383,9 @@ export default function MacroPage() {
</div>
{loading ? (
<div className="text-accent-green animate-pulse font-mono">Loading data...</div>
<LoadingPulse label="Loading FRED series…" height="h-20" className="justify-start" />
) : err ? (
<div className="text-accent-red border border-accent-red/30 rounded-lg p-4">{err}</div>
<ErrorBanner message={err} variant="error" />
) : (
<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">
@@ -377,11 +473,7 @@ export default function MacroPage() {
{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>
)}
<ErrorBanner message={snapErr ? `Snapshot warning: ${snapErr}` : null} variant="warning" />
<MacroCycleHeatmap data={snap} />
</div>
)}
+43 -41
View File
@@ -1,55 +1,57 @@
"use client";
import { useEffect, useState } from "react";
import { useTicker } from "./lib/use-ticker";
import { CommodityOverview } from "./components/overview/CommodityOverview";
import { EquityOverview } from "./components/overview/EquityOverview";
import { ETFOverview } from "./components/overview/ETFOverview";
import { CommodityOverview } from "./components/overview/CommodityOverview";
import { ErrorBanner } from "./components/ui/ErrorBanner";
import { LoadingPulse } from "./components/ui/LoadingPulse";
import { SectionHeading } from "./components/ui/SectionHeading";
import { useApi } from "./lib/use-api";
import { useTicker } from "./lib/use-ticker";
interface OverviewResp {
asset_type?: string;
data?: Record<string, unknown>;
}
export default function OverviewPage() {
const { ticker, initialized } = useTicker();
const [sector, setSector] = useState<Record<string, unknown> | null>(null);
const [health, setHealth] = useState<Record<string, unknown> | null>(null);
const [overview, setOverview] = useState<Record<string, unknown> | null>(null);
const [assetType, setAssetType] = useState<string>("equity");
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!initialized) return;
const ac = new AbortController();
setLoading(true);
setSector(null);
setHealth(null);
setOverview(null);
Promise.all([
fetch(`/api/market/sector/${ticker}`, { signal: ac.signal }).then((r) => r.ok ? r.json() : null),
fetch(`/api/market/health/${ticker}`, { signal: ac.signal }).then((r) => r.ok ? r.json() : null),
fetch(`/api/market/overview/${ticker}`, { signal: ac.signal }).then((r) => r.ok ? r.json() : null),
]).then(([s, h, d]) => {
if (ac.signal.aborted) return;
setSector(s);
setHealth(h);
setAssetType(d?.asset_type || "equity");
setOverview(d?.data || null);
setLoading(false);
}).catch(() => { if (!ac.signal.aborted) setLoading(false); });
return () => ac.abort();
}, [ticker, initialized]);
const sectorUrl = initialized ? `/api/market/sector/${ticker}` : null;
const healthUrl = initialized ? `/api/market/health/${ticker}` : null;
const overviewUrl = initialized ? `/api/market/overview/${ticker}` : null;
if (loading) return <LoadingState />;
const sector = useApi<Record<string, unknown>>(sectorUrl);
const health = useApi<Record<string, unknown>>(healthUrl);
const overview = useApi<OverviewResp>(overviewUrl);
const loading = !initialized || sector.loading || health.loading || overview.loading;
if (loading) return <LoadingPulse label="Loading data..." />;
// Surface only truly catastrophic failures as a banner — per-component empty
// states already handle partial data gracefully.
const fatalError =
sector.error && health.error && overview.error
? "Unable to reach the backend. Please start the API server or refresh."
: null;
if (fatalError) {
return (
<div className="atlas-page">
<SectionHeading level={1}>{ticker} Overview</SectionHeading>
<ErrorBanner variant="error" message={fatalError} />
</div>
);
}
const assetType = overview.data?.asset_type || "equity";
const overviewData = overview.data?.data || {};
if (assetType === "etf") {
return <ETFOverview ticker={ticker} data={overview || {}} />;
return <ETFOverview ticker={ticker} data={overviewData} />;
}
if (assetType === "commodity_future") {
return <CommodityOverview ticker={ticker} data={overview || {}} />;
return <CommodityOverview ticker={ticker} data={overviewData} />;
}
return <EquityOverview ticker={ticker} sector={sector} health={health} />;
}
function LoadingState() {
return (
<div className="flex items-center justify-center h-64">
<div className="text-accent-green animate-pulse font-mono">Loading data...</div>
</div>
);
return <EquityOverview ticker={ticker} sector={sector.data} health={health.data} />;
}
@@ -1,5 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { AlertTriangle, Check, CheckCircle2, CircleX, Pencil, Trash2 } from "lucide-react";
interface Position {
id?: string;
@@ -474,7 +475,13 @@ export default function PortfolioPage() {
{p.pnl_pct != null ? `${p.pnl_pct >= 0 ? "+" : ""}${p.pnl_pct.toFixed(2)}%` : "—"}
</td>
<td className="px-3 py-2">
{p.confidence === "high" ? "✅" : p.confidence === "medium" ? "⚠️" : "❌"}
{p.confidence === "high" ? (
<CheckCircle2 className="h-4 w-4 text-fin-positive" />
) : p.confidence === "medium" ? (
<AlertTriangle className="h-4 w-4 text-fin-warning" />
) : (
<CircleX className="h-4 w-4 text-fin-negative" />
)}
</td>
</>
);
@@ -491,8 +498,9 @@ export default function PortfolioPage() {
>
Cancel
</button>
<button onClick={importOcrPositions} className="bg-accent-green text-bg-primary px-5 py-2 rounded-md font-semibold hover:opacity-90 transition-opacity">
Add All to Portfolio
<button onClick={importOcrPositions} className="inline-flex items-center gap-2 bg-accent-green text-bg-primary px-5 py-2 rounded-md font-semibold hover:opacity-90 transition-opacity">
<Check className="h-4 w-4" />
Add All to Portfolio
</button>
</div>
</div>
@@ -564,8 +572,12 @@ export default function PortfolioPage() {
<td className="px-4 py-2.5 text-right">
{editingId === pid ? (
<div className="flex gap-1 justify-end">
<button onClick={() => handleSaveEdit(pid)} className="w-8 h-8 rounded bg-accent-green/20 hover:bg-accent-green/30"></button>
<button onClick={() => setEditingId(null)} className="w-8 h-8 rounded bg-bg-primary hover:bg-bg-hover"></button>
<button onClick={() => handleSaveEdit(pid)} className="flex h-8 w-8 items-center justify-center rounded bg-accent-green/20 hover:bg-accent-green/30">
<Check className="h-4 w-4" />
</button>
<button onClick={() => setEditingId(null)} className="flex h-8 w-8 items-center justify-center rounded bg-bg-primary hover:bg-bg-hover">
<CircleX className="h-4 w-4" />
</button>
</div>
) : (
<div className="flex gap-1 justify-end">
@@ -574,17 +586,17 @@ export default function PortfolioPage() {
setEditingId(pid);
setEditValues({ qty: String(p.quantity), avgPrice: String(p.avg_price) });
}}
className="w-8 h-8 rounded hover:bg-bg-hover"
className="flex h-8 w-8 items-center justify-center rounded hover:bg-bg-hover"
title="Edit position"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => setDeleteConfirmId(pid)}
className="w-8 h-8 rounded hover:bg-accent-red/20"
className="flex h-8 w-8 items-center justify-center rounded hover:bg-accent-red/20"
title="Delete position"
>
🗑
<Trash2 className="h-4 w-4" />
</button>
</div>
)}
@@ -0,0 +1,291 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Financial performance + quality / risk sections.
* Extracted from the original /report monolith.
*/
import {
BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid,
Tooltip, ResponsiveContainer, Cell, Legend,
} from "recharts";
import { C } from "../design-tokens";
import { getValue } from "../lib/formatters";
import type { FinancialPeriod, ResearchDash, HealthData, StatementsBundle } from "../types";
export function FinancialCharts({ statements }: { statements: StatementsBundle }) {
const is = statements.income_statement;
if (!is || is.length === 0) return null;
const chartData = is.slice(0, 5).reverse().map((p: FinancialPeriod) => {
const period = p as Record<string, unknown>;
const rev = getValue(period, "TotalRevenue|Total Revenue|Revenue");
const ni = getValue(period, "NetIncome|Net Income|Net Income Common Stockholders");
const gp = getValue(period, "GrossProfit|Gross Profit");
const op = getValue(period, "OperatingIncome|Operating Income");
const yr = (period.asOfDate || period.fiscalYear || period.year || "") as string | number;
return {
year: typeof yr === "string" ? yr.slice(0, 4) : String(yr),
revenue: rev ? rev / 1e9 : 0,
netIncome: ni ? ni / 1e9 : 0,
grossMargin: rev && gp ? (gp / rev) * 100 : 0,
opMargin: rev && op ? (op / rev) * 100 : 0,
netMargin: rev && ni ? (ni / rev) * 100 : 0,
};
});
return (
<div className="report-section">
<h2 className="section-title">Income Statement Trends</h2>
<div className="grid grid-cols-2 gap-6">
<div>
<h3 className="chart-title">Revenue & Net Income ($B)</h3>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" />
<XAxis dataKey="year" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip />
<Bar dataKey="revenue" fill={C.blue} name="Revenue" radius={[2, 2, 0, 0]} />
<Bar dataKey="netIncome" fill={C.gold} name="Net Income" radius={[2, 2, 0, 0]} />
<Legend wrapperStyle={{ fontSize: 10 }} />
</BarChart>
</ResponsiveContainer>
</div>
<div>
<h3 className="chart-title">Margin Trends (%)</h3>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" />
<XAxis dataKey="year" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip formatter={(value: number | string) => `${Number(value).toFixed(1)}%`} />
<Line type="monotone" dataKey="grossMargin" stroke={C.green} name="Gross" strokeWidth={2} dot={{ r: 3 }} />
<Line type="monotone" dataKey="opMargin" stroke={C.blue} name="Operating" strokeWidth={2} dot={{ r: 3 }} />
<Line type="monotone" dataKey="netMargin" stroke={C.gold} name="Net" strokeWidth={2} dot={{ r: 3 }} />
<Legend wrapperStyle={{ fontSize: 10 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}
export function BalanceSheetCashFlow({ statements }: { statements: StatementsBundle }) {
const bs = statements.balance_sheet;
const cf = statements.cash_flow;
if ((!bs || bs.length === 0) && (!cf || cf.length === 0)) return null;
const bsData = (bs || []).slice(0, 5).reverse().map((p: FinancialPeriod) => {
const period = p as Record<string, unknown>;
const ta = getValue(period, "TotalAssets|Total Assets");
const tl = getValue(period, "TotalLiabilitiesNetMinorityInterest|Total Liabilities Net Minority Interest|TotalLiab|Total Liabilities");
const te = getValue(period, "StockholdersEquity|Stockholders Equity|TotalStockholderEquity|Total Stockholder Equity");
const cash = getValue(period, "CashAndCashEquivalents|Cash And Cash Equivalents|CashCashEquivalentsAndShortTermInvestments");
const yr = (period.asOfDate || period.fiscalYear || "") as string | number;
return {
year: typeof yr === "string" ? yr.slice(0, 4) : String(yr),
assets: ta ? ta / 1e9 : 0,
liabilities: tl ? tl / 1e9 : 0,
equity: te ? te / 1e9 : 0,
cash: cash ? cash / 1e9 : 0,
};
});
const cfData = (cf || []).slice(0, 5).reverse().map((p: FinancialPeriod) => {
const period = p as Record<string, unknown>;
const ocf = getValue(period, "OperatingCashFlow|Operating Cash Flow|CashFlowFromContinuingOperatingActivities");
const capex = getValue(period, "CapitalExpenditure|Capital Expenditure");
const fcf = getValue(period, "FreeCashFlow|Free Cash Flow") ?? (ocf != null && capex != null ? ocf + capex : null);
const yr = (period.asOfDate || period.fiscalYear || "") as string | number;
return {
year: typeof yr === "string" ? yr.slice(0, 4) : String(yr),
ocf: ocf ? ocf / 1e9 : 0,
capex: capex ? Math.abs(capex) / 1e9 : 0,
fcf: fcf ? fcf / 1e9 : 0,
};
});
return (
<div className="report-section">
<div className="grid grid-cols-2 gap-6">
{bsData.length > 0 && (
<div>
<h3 className="chart-title">Balance Sheet ($B)</h3>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={bsData}>
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" />
<XAxis dataKey="year" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip />
<Bar dataKey="assets" fill={C.blue} name="Total Assets" radius={[2, 2, 0, 0]} />
<Bar dataKey="equity" fill={C.green} name="Equity" radius={[2, 2, 0, 0]} />
<Bar dataKey="cash" fill={C.gold} name="Cash" radius={[2, 2, 0, 0]} />
<Legend wrapperStyle={{ fontSize: 10 }} />
</BarChart>
</ResponsiveContainer>
</div>
)}
{cfData.length > 0 && (
<div>
<h3 className="chart-title">Cash Flow ($B)</h3>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={cfData}>
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" />
<XAxis dataKey="year" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip />
<Bar dataKey="ocf" fill={C.blue} name="Operating CF" radius={[2, 2, 0, 0]} />
<Bar dataKey="fcf" fill={C.green} name="Free CF" radius={[2, 2, 0, 0]} />
<Bar dataKey="capex" fill={C.red} name="CapEx" radius={[2, 2, 0, 0]} />
<Legend wrapperStyle={{ fontSize: 10 }} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</div>
</div>
);
}
export function QualityScores({ research, health }: { research: ResearchDash | null; health: HealthData | null }) {
if (!research && !health) return null;
const { fscore_total = 0, fscore_criteria = [], dupont_tree } = research || {};
const altmanZ = health?.altman_z;
const zLabel = altmanZ == null ? "N/A" : altmanZ > 2.99 ? "Safe" : altmanZ > 1.81 ? "Gray Zone" : "Distress";
const zColor = altmanZ == null ? C.muted : altmanZ > 2.99 ? C.green : altmanZ > 1.81 ? C.gold : C.red;
return (
<div className="report-section">
<h2 className="section-title">Quality Assessment</h2>
<div className="grid grid-cols-3 gap-6">
<div>
<h3 className="chart-title">Piotroski F-Score: {fscore_total}/9</h3>
<div className="flex items-center gap-1 mb-3">
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className="h-6 flex-1 rounded-sm flex items-center justify-center text-[10px] font-bold"
style={{ background: i < fscore_total ? C.green : "#E8ECF0", color: i < fscore_total ? "#fff" : C.muted }}>
{i + 1}
</div>
))}
</div>
<div className="space-y-0.5">
{fscore_criteria.map((c) => (
<div key={c.key} className="flex items-center gap-2 text-[11px]">
<span style={{ color: c.history[0]?.pass_flag ? C.green : C.red }}>{c.history[0]?.pass_flag ? "\u2713" : "\u2717"}</span>
<span style={{ color: C.text }}>{c.label}</span>
</div>
))}
</div>
</div>
<div>
<h3 className="chart-title">Financial Health</h3>
<div className="p-4 rounded mb-3" style={{ background: "#F4F6F9" }}>
<div className="text-center mb-2">
<div className="text-[10px] uppercase" style={{ color: C.muted }}>Altman Z-Score</div>
<div className="text-3xl font-mono font-bold" style={{ color: zColor }}>{altmanZ?.toFixed(2) ?? "N/A"}</div>
<div className="text-[10px] font-bold" style={{ color: zColor }}>{zLabel}</div>
</div>
<div className="space-y-1 mt-3">
{[
{ l: "Current Ratio", v: health?.current_ratio?.toFixed(2) },
{ l: "Interest Coverage", v: health?.interest_coverage?.toFixed(1) },
{ l: "Debt/Equity", v: health?.debt_to_equity?.toFixed(2) },
].map((r) => (
<div key={r.l} className="flex justify-between text-[11px]">
<span style={{ color: C.muted }}>{r.l}</span>
<span className="font-mono" style={{ color: C.navy }}>{r.v ?? "N/A"}</span>
</div>
))}
</div>
</div>
{health?.red_flags && health.red_flags.length > 0 && (
<div>
<div className="text-[10px] uppercase font-bold mb-1" style={{ color: C.red }}>Red Flags</div>
{health.red_flags.slice(0, 4).map((f, i) => (
<div key={i} className="text-[10px] mb-0.5" style={{ color: C.red }}>&bull; {f}</div>
))}
</div>
)}
</div>
{dupont_tree && (
<div>
<h3 className="chart-title">DuPont ROE Decomposition</h3>
<div className="p-4 rounded" style={{ background: "#F4F6F9" }}>
<div className="text-center mb-3">
<div className="text-[10px] uppercase" style={{ color: C.muted }}>Return on Equity</div>
<div className="text-3xl font-mono font-bold" style={{ color: C.navy }}>{dupont_tree.root.value.toFixed(1)}%</div>
</div>
<div className="grid grid-cols-3 gap-2 text-center">
{[
{ label: "Net Margin", val: `${dupont_tree.npm.value.toFixed(1)}%`, trend: dupont_tree.npm.trend },
{ label: "Asset T/O", val: `${dupont_tree.asset_turnover.value.toFixed(2)}x`, trend: dupont_tree.asset_turnover.trend },
{ label: "Equity Mult", val: `${dupont_tree.equity_mult.value.toFixed(2)}x`, trend: dupont_tree.equity_mult.trend },
].map((d) => (
<div key={d.label} className="p-2 rounded" style={{ background: "#fff" }}>
<div className="text-[9px] uppercase" style={{ color: C.muted }}>{d.label}</div>
<div className="text-sm font-mono font-bold" style={{ color: C.navy }}>{d.val}</div>
<div className="text-[9px]" style={{ color: d.trend === "up" ? C.green : d.trend === "down" ? C.red : C.muted }}>
{d.trend === "up" ? "\u25B2" : d.trend === "down" ? "\u25BC" : "\u25C6"}
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
</div>
);
}
export function WaterfallChart({ waterfall }: { waterfall: ResearchDash["waterfall"] }) {
if (!waterfall || waterfall.length === 0) return null;
const data = waterfall.map((w) => ({
name: w.label.length > 14 ? w.label.slice(0, 14) + ".." : w.label,
value: w.value / 1e9,
fill: w.step_type === "total" ? C.navy : w.value >= 0 ? C.green : C.red,
}));
return (
<div className="report-section">
<h2 className="section-title">Operating Income Bridge ($B)</h2>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" />
<XAxis dataKey="name" tick={{ fontSize: 9 }} angle={-20} textAnchor="end" height={50} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip />
<Bar dataKey="value" radius={[2, 2, 0, 0]}>{data.map((d, i) => <Cell key={i} fill={d.fill} />)}</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
}
export function AnomalyTable({ anomalies }: { anomalies: ResearchDash["anomalies"] }) {
if (!anomalies || anomalies.length === 0) return null;
return (
<div className="report-section">
<h2 className="section-title">YoY Anomalies (&gt;30% Change)</h2>
<table className="w-full text-xs">
<thead>
<tr style={{ borderBottom: `2px solid ${C.navy}` }}>
<th className="text-left py-1.5">Line Item</th>
<th className="text-right py-1.5">YoY Change</th>
<th className="text-center py-1.5">Direction</th>
</tr>
</thead>
<tbody>
{anomalies.slice(0, 12).map((a) => (
<tr key={a.account_key} style={{ borderBottom: `1px solid ${C.lightGray}` }}>
<td className="py-1" style={{ color: C.text }}>{a.display_name}</td>
<td className="text-right font-mono" style={{ color: a.direction === "up" ? C.green : C.red }}>{a.change_pct != null ? `${a.change_pct > 0 ? "+" : ""}${a.change_pct.toFixed(1)}%` : "N/A"}</td>
<td className="text-center">{a.direction === "up" ? "\u25B2" : "\u25BC"}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,192 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Cover, TOC, Investment Snapshot (KpiRow + CompanyProfile), Disclaimer.
* Pure presentational components extracted from the original 1400-line page.
*/
import { C } from "../design-tokens";
import { fmtB, fmtPct, fmtPrice, renderMarkdown } from "../lib/formatters";
import type { ConsensusData, DCFResult, RelativeValData, InstitutionalData } from "../types";
export function CoverPage({
ticker, info, consensus, dcf, relativeVal,
}: {
ticker: string;
info: Record<string, any>;
consensus: ConsensusData | null;
dcf: DCFResult | null;
relativeVal: RelativeValData | null;
}) {
const now = new Date();
const dateStr = now.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
const price = info.currentPrice || info.regularMarketPrice || 0;
const target = consensus?.target_mean ?? info.targetMeanPrice ?? 0;
const upside = price > 0 && target > 0 ? ((target - price) / price) * 100 : 0;
const rec = consensus?.recommendation?.toUpperCase() || "N/A";
const recColor = rec.includes("BUY") || rec.includes("STRONG") ? C.green : rec.includes("SELL") ? C.red : C.gold;
return (
<div className="report-page cover-page flex flex-col items-center justify-center text-center">
<div className="mb-10">
<div className="text-sm tracking-[0.3em] uppercase" style={{ color: C.gold }}>ATLAS TERMINAL</div>
<div className="text-xs tracking-[0.2em] uppercase mt-1" style={{ color: C.muted }}>Institutional Equity Research</div>
</div>
<div className="w-24 h-px mb-10" style={{ background: C.gold }} />
<h1 className="text-5xl font-serif font-bold mb-3" style={{ color: C.navy }}>{info.longName || ticker}</h1>
<p className="text-xl mb-1" style={{ color: C.blue }}>{ticker} &mdash; {info.exchange || ""}</p>
<p className="text-base mb-6" style={{ color: C.muted }}>{info.sector || ""} &bull; {info.industry || ""}</p>
<div className="inline-flex items-center gap-3 px-6 py-3 rounded-lg mb-8" style={{ background: "#F4F6F9", border: `2px solid ${recColor}` }}>
<div className="text-xs uppercase tracking-wider" style={{ color: C.muted }}>Consensus</div>
<div className="text-2xl font-bold font-mono" style={{ color: recColor }}>{rec}</div>
<div className="w-px h-8" style={{ background: C.lightGray }} />
<div className="text-right">
<div className="text-xs" style={{ color: C.muted }}>Target</div>
<div className="font-mono font-bold" style={{ color: C.navy }}>{fmtPrice(target)}</div>
</div>
<div className="text-right">
<div className="text-xs" style={{ color: C.muted }}>Upside</div>
<div className="font-mono font-bold" style={{ color: upside >= 0 ? C.green : C.red }}>{fmtPct(upside)}</div>
</div>
</div>
<div className="grid grid-cols-4 gap-6 mb-8" style={{ maxWidth: 640 }}>
{[
{ label: "Price", value: fmtPrice(price) },
{ label: "Market Cap", value: fmtB(info.marketCap) },
{ label: relativeVal ? `${relativeVal.method} Fair Value` : "DCF Fair Value", value: relativeVal ? fmtPrice(relativeVal.base.value) : dcf?.base != null ? fmtPrice(dcf.base) : "N/A" },
{ label: "Analysts", value: consensus ? `${consensus.num_analysts}` : "N/A" },
].map((k) => (
<div key={k.label}>
<div className="text-xs uppercase tracking-wider" style={{ color: C.muted }}>{k.label}</div>
<div className="text-xl font-mono font-bold" style={{ color: C.navy }}>{k.value}</div>
</div>
))}
</div>
<div className="w-24 h-px my-6" style={{ background: C.gold }} />
<p className="text-sm" style={{ color: C.muted }}>{dateStr}</p>
</div>
);
}
export function TableOfContents({ hasInstitutional }: { hasInstitutional: boolean }) {
const sections = [
"Investment Snapshot & Key Metrics",
"Company Profile",
"Financial Performance",
"Balance Sheet & Cash Flow",
"Quality Assessment — F-Score & DuPont",
"Operating Income Bridge",
"YoY Anomalies",
"Valuation — DCF 3-Scenario Analysis",
"Sensitivity Matrix & Monte Carlo",
"Peer Comparison",
"Earnings Analysis",
...(hasInstitutional ? [
"Executive Summary (AI)",
"Goldman Sachs / Morgan Stanley / JP Morgan",
"BlackRock / Bridgewater / Berkshire Hathaway",
"Citadel / Two Sigma / Elliott Mgmt / ARK Invest",
] : []),
"Disclaimer",
];
return (
<div className="report-page">
<div className="page-header">Table of Contents</div>
<div className="space-y-2 mt-4">
{sections.map((s, i) => (
<div key={i} className="flex items-center gap-2 text-sm" style={{ color: C.text }}>
<span className="font-mono text-xs w-6 text-right" style={{ color: C.blue }}>{i + 1}.</span>
<span className="flex-1">{s}</span>
<span className="flex-1 border-b border-dotted" style={{ borderColor: C.lightGray }} />
<span className="font-mono text-xs" style={{ color: C.muted }}>{i + 2}</span>
</div>
))}
</div>
</div>
);
}
export function KpiRow({ info }: { info: Record<string, any> }) {
const kpis = [
{ label: "Revenue", value: fmtB(info.totalRevenue), sub: fmtPct((info.revenueGrowth || 0) * 100) },
{ label: "Net Income", value: fmtB(info.netIncomeToCommon), sub: `Margin ${((info.profitMargins || 0) * 100).toFixed(1)}%` },
{ label: "Free Cash Flow", value: fmtB(info.freeCashflow), sub: `Yield ${info.freeCashflow && info.marketCap ? ((info.freeCashflow / info.marketCap) * 100).toFixed(1) : "N/A"}%` },
{ label: "ROE", value: `${((info.returnOnEquity || 0) * 100).toFixed(1)}%`, sub: `ROA ${((info.returnOnAssets || 0) * 100).toFixed(1)}%` },
{ label: "P/E (TTM)", value: info.trailingPE ? `${info.trailingPE.toFixed(1)}x` : "N/A", sub: `Fwd ${info.forwardPE ? info.forwardPE.toFixed(1) + "x" : "N/A"}` },
{ label: "EV/EBITDA", value: info.enterpriseToEbitda ? `${info.enterpriseToEbitda.toFixed(1)}x` : "N/A", sub: `D/E ${info.debtToEquity ?? "N/A"}` },
];
return (
<div className="grid grid-cols-6 gap-3 mb-6">
{kpis.map((k) => (
<div key={k.label} className="text-center p-3 rounded" style={{ background: "#F4F6F9" }}>
<div className="text-[10px] uppercase tracking-wider mb-1" style={{ color: C.muted }}>{k.label}</div>
<div className="text-lg font-mono font-bold" style={{ color: C.navy }}>{k.value}</div>
<div className="text-[10px]" style={{ color: C.blue }}>{k.sub}</div>
</div>
))}
</div>
);
}
export function CompanyProfile({ info }: { info: Record<string, any> }) {
const desc = info.longBusinessSummary || info.description || "";
if (!desc) return null;
const cur: string = info.currency || "USD";
const stats = [
{ l: "Employees", v: info.fullTimeEmployees ? Number(info.fullTimeEmployees).toLocaleString() : "—" },
{ l: "Country", v: info.country || "—" },
{ l: "Exchange", v: info.exchange || "—" },
{ l: "52W High", v: fmtPrice(info.fiftyTwoWeekHigh, cur) },
{ l: "52W Low", v: fmtPrice(info.fiftyTwoWeekLow, cur) },
{ l: "Beta", v: info.beta ? Number(info.beta).toFixed(2) : "—" },
{ l: "Avg Volume", v: info.averageVolume ? `${(info.averageVolume / 1e6).toFixed(1)}M` : "—" },
{ l: "Dividend Yield", v: info.dividendYield ? `${(info.dividendYield * 100).toFixed(2)}%` : "—" },
];
return (
<div className="report-section">
<h2 className="section-title">Company Profile</h2>
<p className="text-xs leading-relaxed mb-4" style={{ color: C.text }}>{desc.length > 800 ? desc.slice(0, 800) + "..." : desc}</p>
<div className="grid grid-cols-4 gap-x-6 gap-y-2">
{stats.map((s) => (
<div key={s.l} className="flex justify-between text-xs border-b py-1" style={{ borderColor: C.lightGray }}>
<span style={{ color: C.muted }}>{s.l}</span>
<span className="font-mono" style={{ color: C.navy }}>{s.v}</span>
</div>
))}
</div>
</div>
);
}
export function ExecutiveSummaryBlock({ institutional }: { institutional: InstitutionalData | null }) {
if (!institutional?.sections?.executive_summary) return null;
return (
<div className="report-section" style={{ background: "#F4F6F9", borderLeft: `4px solid ${C.gold}`, padding: "16px 20px" }}>
<div className="text-sm leading-relaxed" style={{ color: C.text }}
dangerouslySetInnerHTML={{ __html: renderMarkdown(institutional.sections.executive_summary) }} />
</div>
);
}
export function Disclaimer() {
return (
<div className="report-page flex flex-col justify-end">
<div className="w-full h-px mb-6" style={{ background: C.lightGray }} />
<h2 className="text-sm font-serif font-bold mb-3" style={{ color: C.navy }}>Disclaimer</h2>
<p className="text-[10px] leading-relaxed" style={{ color: C.muted }}>
This report was generated by ATLAS Terminal&apos;s AI analysis engine using Gemini AI and pre-computed
quantitative data from public sources (SEC EDGAR, Yahoo Finance). This is NOT investment advice.
All financial data is sourced from public filings and market data providers and may contain
errors or be outdated. The AI-generated perspectives are simulated institutional viewpoints
and do not represent the actual views of any named financial institution. Past performance
does not guarantee future results. Always consult a qualified financial advisor before making
investment decisions.
</p>
<div className="mt-8 text-center">
<div className="text-xs tracking-[0.2em] uppercase" style={{ color: C.gold }}>ATLAS TERMINAL</div>
<div className="text-[10px] mt-1" style={{ color: C.muted }}>Advanced Terminal for Liquid Asset Surveillance</div>
<div className="text-[10px] mt-1" style={{ color: C.muted }}>Generated {new Date().toISOString().slice(0, 16).replace("T", " ")} UTC</div>
</div>
</div>
);
}
@@ -0,0 +1,19 @@
/**
* Morgan Stanley Blue design tokens used by the institutional report.
*
* NOTE: P1-8 will hoist these into `tailwind.config.ts` so the rest of the app
* shares the same palette. Until then, both this constant and the global
* tailwind tokens (Terminal Noir) coexist intentionally.
*/
export const C = {
navy: "#1B2A4A",
blue: "#2E5B9A",
gold: "#C4A35A",
green: "#2D8B5E",
red: "#C0392B",
gray: "#6B7B8D",
lightGray: "#E8ECF0",
bg: "#FFFFFF",
text: "#1A1A2E",
muted: "#6B7B8D",
} as const;
@@ -0,0 +1,77 @@
/**
* Formatting + lookup helpers shared across every report section.
*
* `getValue` implements the multi-key column lookup pattern documented in
* `claude.md` §2.5 yfinance and yahooquery use different field names for the
* same metric, so callers pass a pipe-separated list of candidates.
*/
/** Map ISO currency codes → display symbol and decimal places. */
const CURRENCY_MAP: Record<string, { sym: string; decimals: number }> = {
USD: { sym: "$", decimals: 2 },
KRW: { sym: "₩", decimals: 0 },
JPY: { sym: "¥", decimals: 0 },
CNY: { sym: "¥", decimals: 2 },
HKD: { sym: "HK$", decimals: 2 },
EUR: { sym: "€", decimals: 2 },
GBP: { sym: "£", decimals: 2 },
INR: { sym: "₹", decimals: 0 },
TWD: { sym: "NT$", decimals: 0 },
SGD: { sym: "S$", decimals: 2 },
CAD: { sym: "C$", decimals: 2 },
AUD: { sym: "A$", decimals: 2 },
};
export function currencySymbol(currency?: string | null): string {
if (!currency) return "$";
return CURRENCY_MAP[currency.toUpperCase()]?.sym ?? currency;
}
export function fmtB(v: number | null | undefined, currency?: string | null): string {
if (v == null || isNaN(v)) return "N/A";
const s = currencySymbol(currency);
if (Math.abs(v) >= 1e12) return `${s}${(v / 1e12).toFixed(1)}T`;
if (Math.abs(v) >= 1e9) return `${s}${(v / 1e9).toFixed(1)}B`;
if (Math.abs(v) >= 1e6) return `${s}${(v / 1e6).toFixed(0)}M`;
return `${s}${v.toFixed(0)}`;
}
export function fmtPct(v: number | null | undefined): string {
if (v == null || isNaN(v)) return "N/A";
return `${v >= 0 ? "+" : ""}${v.toFixed(1)}%`;
}
export function fmtPrice(v: number | null | undefined, currency?: string | null): string {
if (v == null || isNaN(v)) return "N/A";
const info = CURRENCY_MAP[(currency ?? "USD").toUpperCase()] ?? { sym: currencySymbol(currency), decimals: 2 };
return `${info.sym}${v.toLocaleString("en-US", { minimumFractionDigits: info.decimals, maximumFractionDigits: info.decimals })}`;
}
export function getValue(data: Record<string, unknown>, key: string): number | null {
for (const k of key.split("|")) {
const v = data[k.trim()];
if (v != null && typeof v === "number" && !isNaN(v)) return v;
}
return null;
}
export async function fetchJson<T = unknown>(url: string, opts?: RequestInit): Promise<T | null> {
try {
const r = await fetch(url, opts);
return r.ok ? ((await r.json()) as T) : null;
} catch {
return null;
}
}
/**
* Tiny markdownHTML used by AI section bodies. Intentionally minimal we
* trust Gemini's structured output and avoid pulling in a full markdown lib.
*/
export function renderMarkdown(text: string): string {
return text
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
.replace(/\n- /g, "<br/>&bull; ")
.replace(/\n\* /g, "<br/>&bull; ")
.replace(/\n/g, "<br/>");
}
@@ -0,0 +1,126 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { PeerData, RelativeValData, ValuationTier } from "../types";
/**
* Pick the appropriate valuation framework given the company's profitability
* profile. Mirrors the 4-tier policy described in the report design notes:
* 1. DCF positive FCF
* 2. EV/EBITDA negative FCF but positive EBITDA (capex-heavy)
* 3. P/S both negative but revenue is growing >10% YoY
* 4. P/B (NAV) fallback for asset-heavy or distressed names
*/
export function detectValuationTier(
fcf: number | null,
ebitda: number | null,
revenueGrowth: number | null,
): ValuationTier {
if (fcf != null && fcf > 0) return "dcf";
if (ebitda != null && ebitda > 0) return "ev_ebitda";
if (revenueGrowth != null && revenueGrowth > 0.10) return "ps_revenue";
return "pb_nav";
}
interface DcfInputsLite {
fcf: number;
total_debt: number;
cash: number;
shares: number;
}
export function buildRelativeVal(
tier: ValuationTier,
peers: PeerData | null,
info: Record<string, any>,
di: DcfInputsLite | null,
hi: any,
): RelativeValData | null {
if (!di || !di.shares || di.shares <= 0) return null;
const netDebt = (di.total_debt || 0) - (di.cash || 0);
const revenue = hi?.revenue || info.totalRevenue || 0;
const ebitda = hi?.ebitda || 0;
const fcf = hi?.free_cash_flow ?? info.freeCashflow ?? di.fcf ?? 0;
const revGrowth = hi?.revenue_growth ?? info.revenueGrowth ?? null;
const profitMargin = hi?.profit_margin ?? info.profitMargins ?? 0;
const rule40 = revGrowth != null ? revGrowth * 100 + profitMargin * 100 : null;
const cash = di.cash || 0;
const qBurn = fcf < 0 ? Math.abs(fcf) / 4 : 0;
const cashRunway = qBurn > 0 ? cash / qBurn : null;
if (tier === "ev_ebitda") {
const peerAvg = peers?.averages?.ev_ebitda ?? 12;
const impliedEV = peerAvg * ebitda;
const baseVal = (impliedEV - netDebt) / di.shares;
return {
tier,
tierLabel: "EV/EBITDA Relative Valuation",
tierReason:
"Free cash flow is negative due to heavy capital investment, but EBITDA is positive — the company generates operating profit before reinvestment.",
method: "EV/EBITDA",
multipleName: "EV/EBITDA",
peerAvgMultiple: peerAvg,
companyMetric: ebitda,
metricLabel: "EBITDA",
bear: { multiple: peerAvg * 0.7, value: (peerAvg * 0.7 * ebitda - netDebt) / di.shares },
base: { multiple: peerAvg, value: baseVal },
bull: { multiple: peerAvg * 1.3, value: (peerAvg * 1.3 * ebitda - netDebt) / di.shares },
netDebt,
shares: di.shares,
cashRunwayQuarters: cashRunway,
revenueGrowth: revGrowth,
rule40,
ebitda,
fcf,
};
}
if (tier === "ps_revenue") {
const peerAvg = peers?.averages?.ps ?? 4;
const impliedMC = peerAvg * revenue;
const baseVal = impliedMC / di.shares;
return {
tier,
tierLabel: "Price/Sales Relative Valuation",
tierReason:
"Both FCF and EBITDA are negative, but revenue is growing rapidly. P/S (Price-to-Sales) multiple is the appropriate valuation framework for high-growth, pre-profit companies.",
method: "P/S",
multipleName: "P/S",
peerAvgMultiple: peerAvg,
companyMetric: revenue,
metricLabel: "Revenue",
bear: { multiple: peerAvg * 0.6, value: (peerAvg * 0.6 * revenue) / di.shares },
base: { multiple: peerAvg, value: baseVal },
bull: { multiple: peerAvg * 1.5, value: (peerAvg * 1.5 * revenue) / di.shares },
netDebt,
shares: di.shares,
cashRunwayQuarters: cashRunway,
revenueGrowth: revGrowth,
rule40,
ebitda,
fcf,
};
}
// pb_nav
const bookVal = hi?.book_value || 0;
const peerAvg = peers?.averages?.pb ?? 2;
const baseVal = bookVal > 0 ? bookVal * peerAvg : 0;
return {
tier,
tierLabel: "Price/Book (NAV) Valuation",
tierReason:
"FCF, EBITDA, and revenue growth are all weak or negative. Asset-based valuation (P/B) provides the most relevant framework.",
method: "P/B",
multipleName: "P/B",
peerAvgMultiple: peerAvg,
companyMetric: bookVal * di.shares,
metricLabel: "Book Value",
bear: { multiple: peerAvg * 0.6, value: bookVal * peerAvg * 0.6 },
base: { multiple: peerAvg, value: baseVal },
bull: { multiple: peerAvg * 1.5, value: bookVal * peerAvg * 1.5 },
netDebt,
shares: di.shares,
cashRunwayQuarters: cashRunway,
revenueGrowth: revGrowth,
rule40,
ebitda,
fcf,
};
}
+109 -44
View File
@@ -1,20 +1,14 @@
"use client";
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useCallback, useState } from "react";
import { BarChart3, Info } from "lucide-react";
import {
BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid,
Tooltip, ResponsiveContainer, Cell, Legend, ReferenceLine,
} from "recharts";
import { useTicker } from "../lib/use-ticker";
/*
Design Tokens "Morgan Stanley Blue"
*/
const C = {
navy: "#1B2A4A", blue: "#2E5B9A", gold: "#C4A35A",
green: "#2D8B5E", red: "#C0392B", gray: "#6B7B8D",
lightGray: "#E8ECF0", bg: "#FFFFFF", text: "#1A1A2E", muted: "#6B7B8D",
};
import { C } from "./design-tokens";
/*
Types
@@ -68,20 +62,31 @@ interface RelativeValData {
/*
Utility
*/
function fmtB(v: number | null | undefined): string {
const CURRENCY_META: Record<string, { sym: string; dec: number }> = {
USD: { sym: "$", dec: 2 }, KRW: { sym: "₩", dec: 0 },
JPY: { sym: "¥", dec: 0 }, CNY: { sym: "¥", dec: 2 },
HKD: { sym: "HK$", dec: 2 }, EUR: { sym: "€", dec: 2 },
GBP: { sym: "£", dec: 2 }, INR: { sym: "₹", dec: 0 },
TWD: { sym: "NT$", dec: 0 }, SGD: { sym: "S$", dec: 2 },
CAD: { sym: "C$", dec: 2 }, AUD: { sym: "A$", dec: 2 },
};
function curSym(c?: string | null) { return CURRENCY_META[(c ?? "USD").toUpperCase()]?.sym ?? "$"; }
function fmtB(v: number | null | undefined, c?: string | null): string {
if (v == null || isNaN(v)) return "N/A";
if (Math.abs(v) >= 1e12) return `$${(v / 1e12).toFixed(1)}T`;
if (Math.abs(v) >= 1e9) return `$${(v / 1e9).toFixed(1)}B`;
if (Math.abs(v) >= 1e6) return `$${(v / 1e6).toFixed(0)}M`;
return `$${v.toFixed(0)}`;
const s = curSym(c);
if (Math.abs(v) >= 1e12) return `${s}${(v / 1e12).toFixed(1)}T`;
if (Math.abs(v) >= 1e9) return `${s}${(v / 1e9).toFixed(1)}B`;
if (Math.abs(v) >= 1e6) return `${s}${(v / 1e6).toFixed(0)}M`;
return `${s}${v.toFixed(0)}`;
}
function fmtPct(v: number | null | undefined): string {
if (v == null || isNaN(v)) return "N/A";
return `${v >= 0 ? "+" : ""}${v.toFixed(1)}%`;
}
function fmtPrice(v: number | null | undefined): string {
function fmtPrice(v: number | null | undefined, c?: string | null): string {
if (v == null || isNaN(v)) return "N/A";
return `$${v.toFixed(2)}`;
const meta = CURRENCY_META[(c ?? "USD").toUpperCase()] ?? { sym: curSym(c), dec: 2 };
return `${meta.sym}${v.toLocaleString("en-US", { minimumFractionDigits: meta.dec, maximumFractionDigits: meta.dec })}`;
}
function getValue(data: Record<string, any>, key: string): number | null {
for (const k of key.split("|")) { const v = data[k.trim()]; if (v != null && typeof v === "number" && !isNaN(v)) return v; }
@@ -93,6 +98,28 @@ async function fetchJson(url: string, opts?: RequestInit) {
function renderMarkdown(text: string) {
return text.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>").replace(/\n- /g, "<br/>&bull; ").replace(/\n\* /g, "<br/>&bull; ").replace(/\n/g, "<br/>");
}
function normalizeAiNotice(message?: string | null): string {
const text = (message || "").trim();
if (!text) {
return "AI analysis could not be generated. The quantitative report was generated successfully.";
}
if (text.includes("Gemini API 키가 없어")) {
return "Gemini API key is missing. Add your key in Settings to enable the Wall Street 10 analysis.";
}
if (text.includes("AI 분석 호출에 실패했습니다")) {
return "AI analysis could not be generated. The quantitative report was generated successfully.";
}
if (text.includes("Gemini API timeout")) {
return "Gemini timed out while generating the AI section. The quantitative report was generated successfully.";
}
if (text.includes("Gemini API error")) {
return "Gemini returned an API error. The quantitative report was generated successfully.";
}
if (text.includes("Gemini request failed")) {
return "Gemini request failed. The quantitative report was generated successfully.";
}
return text;
}
/*
SECTION COMPONENTS
@@ -101,11 +128,14 @@ function renderMarkdown(text: string) {
function CoverPage({ ticker, info, consensus, dcf, relativeVal }: { ticker: string; info: Record<string, any>; consensus: ConsensusData | null; dcf: DCFResult | null; relativeVal: RelativeValData | null }) {
const now = new Date();
const dateStr = now.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
const cur: string = info.currency || "USD";
const price = info.currentPrice || info.regularMarketPrice || 0;
const target = consensus?.target_mean ?? info.targetMeanPrice ?? 0;
const upside = price > 0 && target > 0 ? ((target - price) / price) * 100 : 0;
const rec = consensus?.recommendation?.toUpperCase() || "N/A";
const recColor = rec.includes("BUY") || rec.includes("STRONG") ? C.green : rec.includes("SELL") ? C.red : C.gold;
// NONE / null / missing consensus → show "N/A" rather than "NONE"
const recDisplay = !rec || rec === "NONE" || rec === "N/A" ? "N/A" : rec;
const recColor = recDisplay.includes("BUY") || recDisplay.includes("STRONG") ? C.green : recDisplay.includes("SELL") ? C.red : C.gold;
return (
<div className="report-page cover-page flex flex-col items-center justify-center text-center">
@@ -121,11 +151,11 @@ function CoverPage({ ticker, info, consensus, dcf, relativeVal }: { ticker: stri
{/* Rating Badge */}
<div className="inline-flex items-center gap-3 px-6 py-3 rounded-lg mb-8" style={{ background: "#F4F6F9", border: `2px solid ${recColor}` }}>
<div className="text-xs uppercase tracking-wider" style={{ color: C.muted }}>Consensus</div>
<div className="text-2xl font-bold font-mono" style={{ color: recColor }}>{rec}</div>
<div className="text-2xl font-bold font-mono" style={{ color: recColor }}>{recDisplay}</div>
<div className="w-px h-8" style={{ background: C.lightGray }} />
<div className="text-right">
<div className="text-xs" style={{ color: C.muted }}>Target</div>
<div className="font-mono font-bold" style={{ color: C.navy }}>{fmtPrice(target)}</div>
<div className="font-mono font-bold" style={{ color: C.navy }}>{fmtPrice(target, cur)}</div>
</div>
<div className="text-right">
<div className="text-xs" style={{ color: C.muted }}>Upside</div>
@@ -135,9 +165,9 @@ function CoverPage({ ticker, info, consensus, dcf, relativeVal }: { ticker: stri
<div className="grid grid-cols-4 gap-6 mb-8" style={{ maxWidth: 640 }}>
{[
{ label: "Price", value: fmtPrice(price) },
{ label: "Market Cap", value: fmtB(info.marketCap) },
{ label: relativeVal ? `${relativeVal.method} Fair Value` : "DCF Fair Value", value: relativeVal ? fmtPrice(relativeVal.base.value) : dcf?.base != null ? fmtPrice(dcf.base) : "N/A" },
{ label: "Price", value: fmtPrice(price, cur) },
{ label: "Market Cap", value: fmtB(info.marketCap, cur) },
{ label: relativeVal ? `${relativeVal.method} Fair Value` : "DCF Fair Value", value: relativeVal ? fmtPrice(relativeVal.base.value, cur) : dcf?.base != null ? fmtPrice(dcf.base, cur) : "N/A" },
{ label: "Analysts", value: consensus ? `${consensus.num_analysts}` : "N/A" },
].map((k) => (
<div key={k.label}>
@@ -191,10 +221,11 @@ function TableOfContents({ hasInstitutional }: { hasInstitutional: boolean }) {
}
function KpiRow({ info }: { info: Record<string, any> }) {
const cur: string = info.currency || "USD";
const kpis = [
{ label: "Revenue", value: fmtB(info.totalRevenue), sub: fmtPct((info.revenueGrowth || 0) * 100) },
{ label: "Net Income", value: fmtB(info.netIncomeToCommon), sub: `Margin ${((info.profitMargins || 0) * 100).toFixed(1)}%` },
{ label: "Free Cash Flow", value: fmtB(info.freeCashflow), sub: `Yield ${info.freeCashflow && info.marketCap ? ((info.freeCashflow / info.marketCap) * 100).toFixed(1) : "N/A"}%` },
{ label: "Revenue", value: fmtB(info.totalRevenue, cur), sub: fmtPct((info.revenueGrowth || 0) * 100) },
{ label: "Net Income", value: fmtB(info.netIncomeToCommon, cur), sub: `Margin ${((info.profitMargins || 0) * 100).toFixed(1)}%` },
{ label: "Free Cash Flow", value: fmtB(info.freeCashflow, cur), sub: `Yield ${info.freeCashflow && info.marketCap ? ((info.freeCashflow / info.marketCap) * 100).toFixed(1) : "N/A"}%` },
{ label: "ROE", value: `${((info.returnOnEquity || 0) * 100).toFixed(1)}%`, sub: `ROA ${((info.returnOnAssets || 0) * 100).toFixed(1)}%` },
{ label: "P/E (TTM)", value: info.trailingPE ? `${info.trailingPE.toFixed(1)}x` : "N/A", sub: `Fwd ${info.forwardPE ? info.forwardPE.toFixed(1) + "x" : "N/A"}` },
{ label: "EV/EBITDA", value: info.enterpriseToEbitda ? `${info.enterpriseToEbitda.toFixed(1)}x` : "N/A", sub: `D/E ${info.debtToEquity ?? "N/A"}` },
@@ -215,12 +246,13 @@ function KpiRow({ info }: { info: Record<string, any> }) {
function CompanyProfile({ info }: { info: Record<string, any> }) {
const desc = info.longBusinessSummary || info.description || "";
if (!desc) return null;
const cur: string = info.currency || "USD";
const stats = [
{ l: "Employees", v: info.fullTimeEmployees?.toLocaleString() || "N/A" },
{ l: "Country", v: info.country || "N/A" },
{ l: "Founded", v: info.companyOfficers?.[0]?.fiscalYear || "N/A" },
{ l: "52W High", v: fmtPrice(info.fiftyTwoWeekHigh) },
{ l: "52W Low", v: fmtPrice(info.fiftyTwoWeekLow) },
{ l: "Employees", v: info.fullTimeEmployees ? Number(info.fullTimeEmployees).toLocaleString() : "" },
{ l: "Country", v: info.country || "" },
{ l: "Exchange", v: info.exchange || "" },
{ l: "52W High", v: fmtPrice(info.fiftyTwoWeekHigh, cur) },
{ l: "52W Low", v: fmtPrice(info.fiftyTwoWeekLow, cur) },
{ l: "Beta", v: info.beta ? info.beta.toFixed(2) : "N/A" },
{ l: "Avg Volume", v: info.averageVolume ? `${(info.averageVolume / 1e6).toFixed(1)}M` : "N/A" },
{ l: "Dividend Yield", v: info.dividendYield ? `${(info.dividendYield * 100).toFixed(2)}%` : "N/A" },
@@ -285,7 +317,7 @@ function FinancialCharts({ statements }: { statements: { income_statement?: Fina
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" />
<XAxis dataKey="year" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip />
<Tooltip formatter={(value: number | string) => `${Number(value).toFixed(1)}%`} />
<Line type="monotone" dataKey="grossMargin" stroke={C.green} name="Gross" strokeWidth={2} dot={{ r: 3 }} />
<Line type="monotone" dataKey="opMargin" stroke={C.blue} name="Operating" strokeWidth={2} dot={{ r: 3 }} />
<Line type="monotone" dataKey="netMargin" stroke={C.gold} name="Net" strokeWidth={2} dot={{ r: 3 }} />
@@ -1102,9 +1134,10 @@ export default function ReportPage() {
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState("");
const [error, setError] = useState("");
const [aiNotice, setAiNotice] = useState("");
const generateReport = useCallback(async () => {
setLoading(true); setError(""); setProgress("Phase 1/4 — Gathering market data (9 parallel requests)...");
setLoading(true); setError(""); setAiNotice(""); setProgress("Phase 1/4 — Gathering market data (9 parallel requests)...");
const apiKey = localStorage.getItem("atlas_gemini_key") || "";
try {
@@ -1130,27 +1163,48 @@ export default function ReportPage() {
const mergedInfo: Record<string, any> = {};
if (ov?.data) Object.assign(mergedInfo, {
longName: ov.data.name, sector: ov.data.sector, industry: ov.data.industry,
marketCap: ov.data.market_cap, trailingPE: ov.data.pe_ratio,
marketCap: ov.data.market_cap,
trailingPE: ov.data.trailing_pe ?? ov.data.pe_ratio,
dividendYield: ov.data.dividend_yield ? ov.data.dividend_yield / 100 : 0,
beta: ov.data.beta, fiftyTwoWeekHigh: ov.data.high_52w, fiftyTwoWeekLow: ov.data.low_52w,
currentPrice: ov.data.price, longBusinessSummary: ov.data.description,
currency: ov.data.currency,
exchange: ov.data.exchange, country: ov.data.country,
fullTimeEmployees: ov.data.full_time_employees,
averageVolume: ov.data.average_volume,
forwardPE: ov.data.forward_pe,
enterpriseToEbitda: ov.data.enterprise_to_ebitda,
debtToEquity: ov.data.debt_to_equity,
returnOnEquity: ov.data.return_on_equity,
returnOnAssets: ov.data.return_on_assets,
freeCashflow: ov.data.free_cashflow,
revenueGrowth: ov.data.revenue_growth,
profitMargins: ov.data.profit_margins,
targetMeanPrice: ov.data.target_mean_price,
});
if (sec) Object.assign(mergedInfo, {
sector: sec.sector || mergedInfo.sector, industry: sec.industry || mergedInfo.industry,
marketCap: sec.market_cap || mergedInfo.marketCap, trailingPE: sec.pe_ratio || mergedInfo.trailingPE,
forwardPE: sec.forward_pe, currentPrice: sec.current_price || mergedInfo.currentPrice,
targetMeanPrice: sec.target_mean_price, fiftyTwoWeekHigh: sec.fifty_two_week_high || mergedInfo.fiftyTwoWeekHigh,
forwardPE: sec.forward_pe || mergedInfo.forwardPE,
currentPrice: sec.current_price || mergedInfo.currentPrice,
targetMeanPrice: sec.target_mean_price || mergedInfo.targetMeanPrice,
fiftyTwoWeekHigh: sec.fifty_two_week_high || mergedInfo.fiftyTwoWeekHigh,
fiftyTwoWeekLow: sec.fifty_two_week_low || mergedInfo.fiftyTwoWeekLow,
fullTimeEmployees: sec.employees, exchange: sec.exchange,
debtToEquity: sec.debt_to_equity,
fullTimeEmployees: sec.employees || mergedInfo.fullTimeEmployees,
exchange: sec.exchange || mergedInfo.exchange,
debtToEquity: sec.debt_to_equity || mergedInfo.debtToEquity,
currency: sec.currency || mergedInfo.currency || "USD",
});
if (hi) Object.assign(mergedInfo, {
totalRevenue: hi.revenue, profitMargins: hi.profit_margin,
returnOnEquity: hi.roe, returnOnAssets: hi.roa, freeCashflow: hi.free_cash_flow,
totalRevenue: hi.revenue, profitMargins: hi.profit_margin ?? mergedInfo.profitMargins,
returnOnEquity: hi.roe ?? mergedInfo.returnOnEquity,
returnOnAssets: hi.roa ?? mergedInfo.returnOnAssets,
freeCashflow: hi.free_cash_flow ?? mergedInfo.freeCashflow,
netIncomeToCommon: hi.revenue && hi.profit_margin ? hi.revenue * hi.profit_margin : undefined,
enterpriseToEbitda: hi.ebitda && hi.revenue ? undefined : undefined,
revenueGrowth: hi.revenue_growth, debtToEquity: hi.debt_to_equity ?? mergedInfo.debtToEquity,
revenueGrowth: hi.revenue_growth ?? mergedInfo.revenueGrowth,
debtToEquity: hi.debt_to_equity ?? mergedInfo.debtToEquity,
operatingMargins: hi.operating_margin, grossMargins: hi.gross_margin,
currency: hi.currency || mergedInfo.currency || "USD",
});
if (Object.keys(mergedInfo).length > 0) setInfo(mergedInfo);
@@ -1226,7 +1280,7 @@ export default function ReportPage() {
/* ── Phase 4: Gemini AI ── */
if (!apiKey) {
setError("Quantitative report generated (20+ sections). Add Gemini API key in Settings for Wall Street 10 AI analysis.");
setAiNotice(normalizeAiNotice("Gemini API key is missing. Add your key in Settings to enable the Wall Street 10 analysis."));
} else {
setProgress("Phase 4/4 — Generating Wall Street 10 analysis (Gemini AI, ~15s)...");
const instRes = await fetch("/api/analysis/institutional", {
@@ -1234,7 +1288,10 @@ export default function ReportPage() {
body: JSON.stringify({ ticker, api_key: apiKey }),
});
if (instRes.ok) { setInstitutional(await instRes.json()); }
else { const err = await instRes.json().catch(() => ({})); setError(err.detail || "AI analysis failed. Quantitative data available below."); }
else {
const err = await instRes.json().catch(() => ({}));
setAiNotice(normalizeAiNotice(err.detail || "AI analysis could not be generated. The quantitative report was generated successfully."));
}
}
setProgress("");
@@ -1274,6 +1331,12 @@ export default function ReportPage() {
</div>
{error && <div className="bg-accent-red/10 border border-accent-red/30 rounded-md px-4 py-3 text-accent-red text-sm mb-4">{error}</div>}
{!error && aiNotice && (
<div className="mb-4 flex items-start gap-2 rounded-md border border-accent-yellow/30 bg-accent-yellow/10 px-4 py-3 text-sm text-accent-yellow">
<Info className="mt-0.5 h-4 w-4 shrink-0" />
<span>{aiNotice}</span>
</div>
)}
{loading && (
<div className="bg-bg-card border border-border rounded-lg p-8 text-center">
@@ -1286,7 +1349,9 @@ export default function ReportPage() {
{!hasData && !loading && (
<div className="bg-bg-card border border-border rounded-lg p-8 text-center">
<div className="text-5xl mb-4">📊</div>
<div className="mb-4 flex justify-center text-brand-navy">
<BarChart3 className="h-12 w-12" />
</div>
<h2 className="text-text-primary text-lg font-semibold mb-2">Wall Street 10 Institutional Report</h2>
<p className="text-text-muted text-sm max-w-lg mx-auto mb-4">
Generate a comprehensive 20+ page equity research report featuring DCF valuation (3 scenarios),
@@ -0,0 +1,139 @@
/**
* Type definitions for the institutional report data flow.
*
* Many of the upstream API responses are still untyped on the backend
* (`Dict[str, Any]`), so a number of fields keep loose `Record<string, unknown>`
* shapes. P1-4 / P2-1 will tighten these as Pydantic response models land.
*/
export type FinancialPeriod = Record<string, unknown>;
export interface ResearchDash {
ticker: string;
fscore_total: number;
fscore_criteria: { key: string; label: string; history: { year: number; pass_flag: boolean }[] }[];
dupont_tree?: {
root: { value: number };
npm: { value: number; trend: string };
asset_turnover: { value: number; trend: string };
equity_mult: { value: number; trend: string };
};
sankey: unknown;
waterfall: { id: string; label: string; value: number; cumulative: number; step_type: string }[];
anomalies: { account_key: string; display_name: string; change_pct: number; direction: string }[];
error?: string;
}
export interface InstitutionalData {
ticker: string;
sections: Record<string, string>;
quant_context: string;
}
export interface DCFResult {
base: number | null;
bull: number | null;
bear: number | null;
current_price: number | null;
scenarios: Record<string, { intrinsic_value: number | null; upside: number }>;
}
export interface SensitivityResult {
wacc_values: number[];
tg_values: number[];
matrix: (number | null)[][];
}
export interface MonteCarloResult {
histogram: { counts: number[]; bin_edges: number[] };
mean: number | null;
median: number | null;
percentile_5: number | null;
percentile_95: number | null;
upside_pct: number | null;
current_price: number | null;
}
export interface TornadoItem {
variable: string;
low: number;
high: number;
base: number;
}
export interface ReverseDCFResult {
implied_growth: number | null;
current_price: number | null;
}
export interface ConsensusData {
target_mean: number | null;
target_high: number | null;
target_low: number | null;
target_median: number | null;
recommendation: string;
num_analysts: number;
}
export interface PeerData {
ticker: string;
sector: string;
industry: string;
averages: Record<string, number | null>;
peers: Record<string, unknown>[];
}
export interface EarningsHistory {
date: string;
eps_actual: number | null;
eps_estimate: number | null;
surprise: number;
}
export interface QuarterlyEarnings {
period: string;
revenue: number | null;
earnings: number | null;
}
export interface HealthData {
dupont: Record<string, number>;
altman_z: number | null;
current_ratio: number | null;
interest_coverage: number | null;
debt_to_equity: number | null;
red_flags: string[];
}
export type TechnicalData = Record<string, unknown>;
export type ValuationTier = "dcf" | "ev_ebitda" | "ps_revenue" | "pb_nav";
export interface RelativeValData {
tier: ValuationTier;
tierLabel: string;
tierReason: string;
method: string;
multipleName: string;
peerAvgMultiple: number;
companyMetric: number;
metricLabel: string;
bear: { multiple: number; value: number };
base: { multiple: number; value: number };
bull: { multiple: number; value: number };
netDebt: number;
shares: number;
cashRunwayQuarters: number | null;
revenueGrowth: number | null;
rule40: number | null;
ebitda: number | null;
fcf: number | null;
}
export type InfoMap = Record<string, unknown>;
export interface StatementsBundle {
income_statement?: FinancialPeriod[];
balance_sheet?: FinancialPeriod[];
cash_flow?: FinancialPeriod[];
}
@@ -1,83 +1,69 @@
"use client";
import { useEffect, useState } from "react";
import { ResearchGridLayout } from "../components/research/ResearchGridLayout";
import type { ResearchDashboardPayload } from "../components/research/types";
import { ErrorBanner } from "../components/ui/ErrorBanner";
import { LoadingPulse } from "../components/ui/LoadingPulse";
import { SectionHeading } from "../components/ui/SectionHeading";
import { useApi } from "../lib/use-api";
import { useTicker } from "../lib/use-ticker";
interface OverviewResp {
asset_type?: string;
}
export default function ResearchPage() {
const { ticker, initialized } = useTicker();
const [assetType, setAssetType] = useState<string>("equity");
const [dashboard, setDashboard] = useState<ResearchDashboardPayload | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!initialized) return;
setLoading(true);
setLoadError(null);
Promise.all([
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("Failed to load dashboard data.");
setDashboard(null);
})
.finally(() => setLoading(false));
}, [ticker, initialized]);
const overviewUrl = initialized ? `/api/market/overview/${ticker}` : null;
const dashUrl = initialized ? `/api/research/dashboard/${encodeURIComponent(ticker)}` : null;
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>
);
const overview = useApi<OverviewResp>(overviewUrl);
const dashboard = useApi<ResearchDashboardPayload>(dashUrl);
const assetType = overview.data?.asset_type || "equity";
const loading = overview.loading || dashboard.loading;
if (!initialized || loading) {
return <LoadingPulse label="Loading research…" />;
}
return (
<div>
<h1 className="text-2xl font-bold mb-6">
<span className="text-accent-green">{ticker}</span> Research
</h1>
<div className="atlas-page">
<SectionHeading level={1}>{ticker} Research</SectionHeading>
{assetType === "equity" ? (
loadError ? (
<div className="bg-bg-card border border-border rounded-lg p-5 text-accent-red text-sm">{loadError}</div>
) : dashboard ? (
dashboard.error ? (
<ErrorBanner
variant="info"
message={`${dashboard.error} Some quant widgets may be unavailable — try refreshing or checking the backend.`}
/>
) : dashboard.data ? (
<>
{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} Some widgets may be empty.
</div>
{dashboard.data.error && (
<ErrorBanner
className="mb-4"
variant="info"
message={`${dashboard.data.error} — Some widgets may be empty.`}
/>
)}
<ResearchGridLayout dashboard={dashboard} />
<ResearchGridLayout dashboard={dashboard.data} />
</>
) : (
<div className="bg-bg-card border border-border rounded-lg p-5 text-text-muted text-sm">
<div className="atlas-card p-5 text-sm text-text-muted">
No dashboard data available. Check the API response or try a different ticker.
</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="atlas-card p-5 mb-6">
<h3 className="text-sm font-semibold uppercase tracking-[0.12em] text-text-secondary">ETF Research</h3>
<div className="text-text-secondary text-sm">
Provides Holdings Analysis, Sector Breakdown, and Overlap Analysis. Piotroski F-Score and corporate financial dashboards are not applicable to ETFs.
</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="atlas-card p-5 mb-6">
<h3 className="text-sm font-semibold uppercase tracking-[0.12em] text-text-secondary">Commodity Research</h3>
<div className="text-text-secondary text-sm">
Focuses on Seasonal Analysis and Supply/Demand factors. Equity-specific indicators are not displayed.
</div>
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { chartPalette, lightweightTheme } from "../lib/chart-theme";
interface ScreenerRow {
ticker: string;
@@ -156,19 +157,17 @@ export default function ScreenerPage() {
chart = createChart(el, {
width: el.clientWidth,
height: 320,
layout: { background: { color: "#1A1A26" }, textColor: "#9CA3AF" },
grid: { vertLines: { color: "#2A2A3A" }, horzLines: { color: "#2A2A3A" } },
...lightweightTheme,
crosshair: { mode: 0 },
timeScale: { borderColor: "#2A2A3A" },
});
const strat = chart.addLineSeries({ color: "#00D4AA", lineWidth: 2 });
const strat = chart.addLineSeries({ color: chartPalette.navy, 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 });
const bench = chart.addLineSeries({ color: chartPalette.blue, lineWidth: 2 });
bench.setData(
btResult.dates!.map((d, i) => ({
time: d as string & { __brand?: "Time" },
@@ -203,19 +202,17 @@ export default function ScreenerPage() {
chart = createChart(el, {
width: el.clientWidth,
height: 320,
layout: { background: { color: "#1A1A26" }, textColor: "#9CA3AF" },
grid: { vertLines: { color: "#2A2A3A" }, horzLines: { color: "#2A2A3A" } },
...lightweightTheme,
crosshair: { mode: 0 },
timeScale: { borderColor: "#2A2A3A" },
});
const port = chart.addLineSeries({ color: "#00D4AA", lineWidth: 2 });
const port = chart.addLineSeries({ color: chartPalette.navy, lineWidth: 2 });
port.setData(
ptResult.dates!.map((d, i) => ({
time: d as string & { __brand?: "Time" },
value: ptResult.equity_curve![i],
}))
);
const bench = chart.addLineSeries({ color: "#4DA6FF", lineWidth: 2 });
const bench = chart.addLineSeries({ color: chartPalette.blue, lineWidth: 2 });
bench.setData(
ptResult.dates!.map((d, i) => ({
time: d as string & { __brand?: "Time" },
@@ -1,5 +1,8 @@
"use client";
import { useState, useEffect } from "react";
import { CheckCircle2, CircleOff, LoaderCircle } from "lucide-react";
import { Card } from "../components/ui/Card";
import { SectionHeading } from "../components/ui/SectionHeading";
const KEYS = [
{ id: "atlas_gemini_key", label: "Gemini API Key", placeholder: "AIza..." },
@@ -36,36 +39,38 @@ export default function SettingsPage() {
}
return (
<div className="max-w-2xl">
<h1 className="text-2xl font-bold mb-6">Settings</h1>
<div className="atlas-page max-w-3xl">
<SectionHeading level={1}>Settings</SectionHeading>
{/* Backend Status */}
<div className="bg-bg-card border border-border rounded-lg p-4 mb-6">
<h3 className="text-text-secondary text-sm font-semibold mb-3">System Status</h3>
<Card title="System Status" subtitle="Local keys and backend health for your desk.">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${backendStatus === "ok" ? "bg-accent-green" : backendStatus === "error" ? "bg-accent-red" : "bg-accent-yellow animate-pulse"}`} />
{backendStatus === "ok" ? (
<CheckCircle2 className="h-5 w-5 text-fin-positive" />
) : backendStatus === "error" ? (
<CircleOff className="h-5 w-5 text-fin-negative" />
) : (
<LoaderCircle className="h-5 w-5 animate-spin text-brand-blue" />
)}
<span className="text-text-primary text-sm">
Backend API: {backendStatus === "ok" ? "Connected" : backendStatus === "error" ? "Disconnected" : "Checking..."}
</span>
</div>
</div>
</Card>
{/* API Keys */}
<div className="bg-bg-card border border-border rounded-lg p-5 mb-6">
<h3 className="text-text-secondary text-sm font-semibold mb-4">API Keys</h3>
<Card title="API Keys" subtitle="Stored locally in this browser session.">
<div className="space-y-4">
{KEYS.map((k) => (
<div key={k.id}>
<label className="text-text-muted text-sm mb-1.5 block">{k.label}</label>
<label className="mb-1.5 block text-sm text-text-muted">{k.label}</label>
<div className="flex items-center gap-3">
<input
type="password"
value={values[k.id] || ""}
onChange={(e) => setValues({ ...values, [k.id]: e.target.value })}
placeholder={k.placeholder}
className="flex-1 bg-bg-primary border border-border rounded-md px-3 py-2 text-text-primary outline-none focus:border-accent-green transition-colors"
className="flex-1 rounded-md border border-border bg-surface-raised px-3 py-2 text-text-primary outline-none transition-colors focus:border-brand-blue"
/>
<div className={`w-2.5 h-2.5 rounded-full ${values[k.id] ? "bg-accent-green" : "bg-text-muted"}`} />
<div className={`h-2.5 w-2.5 rounded-full ${values[k.id] ? "bg-fin-positive" : "bg-text-muted"}`} />
</div>
</div>
))}
@@ -73,20 +78,18 @@ export default function SettingsPage() {
<button
onClick={handleSave}
className="mt-5 bg-accent-green text-bg-primary px-6 py-2 rounded-md font-semibold hover:opacity-90 transition-opacity"
className="mt-5 rounded-md bg-brand-navy px-6 py-2 font-semibold text-white transition-colors hover:bg-brand-blue"
>
{saved ? "Saved!" : "Save Keys"}
</button>
</div>
</Card>
{/* Info */}
<div className="bg-bg-card border border-border rounded-lg p-4">
<h3 className="text-text-secondary text-sm font-semibold mb-2">About</h3>
<Card title="About">
<div className="text-text-muted text-sm space-y-1">
<p>ATLAS Terminal v2.0 Advanced Trading & Liquidity Analysis System</p>
<p>API keys are stored locally in your browser. They are never sent to our servers.</p>
</div>
</div>
</Card>
</div>
);
}
@@ -1,5 +1,10 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { ChartContainer } from "../components/ui/ChartContainer";
import { LoadingPulse } from "../components/ui/LoadingPulse";
import { SectionHeading } from "../components/ui/SectionHeading";
import { StatCard } from "../components/ui/StatCard";
import { chartPalette, lightweightTheme } from "../lib/chart-theme";
import { useTicker } from "../lib/use-ticker";
interface Indicators {
@@ -67,10 +72,8 @@ export default function TechnicalPage() {
chart = lc.createChart(chartRef.current!, {
width: chartRef.current!.clientWidth,
height: 400,
layout: { background: { color: "#1A1A26" }, textColor: "#9CA3AF" },
grid: { vertLines: { color: "#2A2A3A" }, horzLines: { color: "#2A2A3A" } },
...lightweightTheme,
crosshair: { mode: 0 },
timeScale: { borderColor: "#2A2A3A" },
});
// v5 API: use addSeries with series type constructor
@@ -80,12 +83,12 @@ export default function TechnicalPage() {
if (CandlestickSeries && typeof chart.addSeries === "function") {
// v5 path
const candlestickSeries = chart.addSeries(CandlestickSeries, {
upColor: "#00D4AA",
downColor: "#FF4757",
borderUpColor: "#00D4AA",
borderDownColor: "#FF4757",
wickUpColor: "#00D4AA",
wickDownColor: "#FF4757",
upColor: chartPalette.green,
downColor: chartPalette.red,
borderUpColor: chartPalette.green,
borderDownColor: chartPalette.red,
wickUpColor: chartPalette.green,
wickDownColor: chartPalette.red,
});
candlestickSeries.setData(bars);
@@ -100,18 +103,18 @@ export default function TechnicalPage() {
bars.map((b: ChartBar) => ({
time: b.time,
value: b.volume,
color: b.close >= b.open ? "rgba(0,212,170,0.3)" : "rgba(255,71,87,0.3)",
color: b.close >= b.open ? "rgba(45,139,94,0.24)" : "rgba(192,57,43,0.24)",
}))
);
} else if (typeof chart.addCandlestickSeries === "function") {
// v4 fallback
const candlestickSeries = chart.addCandlestickSeries({
upColor: "#00D4AA",
downColor: "#FF4757",
borderUpColor: "#00D4AA",
borderDownColor: "#FF4757",
wickUpColor: "#00D4AA",
wickDownColor: "#FF4757",
upColor: chartPalette.green,
downColor: chartPalette.red,
borderUpColor: chartPalette.green,
borderDownColor: chartPalette.red,
wickUpColor: chartPalette.green,
wickDownColor: chartPalette.red,
});
candlestickSeries.setData(bars);
@@ -126,7 +129,7 @@ export default function TechnicalPage() {
bars.map((b: ChartBar) => ({
time: b.time,
value: b.volume,
color: b.close >= b.open ? "rgba(0,212,170,0.3)" : "rgba(255,71,87,0.3)",
color: b.close >= b.open ? "rgba(45,139,94,0.24)" : "rgba(192,57,43,0.24)",
}))
);
}
@@ -147,21 +150,15 @@ export default function TechnicalPage() {
};
}, [bars]);
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 rsiColor = indicators?.rsi_14
? indicators.rsi_14 > 70 ? "text-accent-red" : indicators.rsi_14 < 30 ? "text-accent-green" : "text-text-primary"
: "text-text-primary";
if (loading) return <LoadingPulse label="Loading technical data…" />;
const macdSignal = indicators?.macd
? indicators.macd.histogram > 0 ? "Bullish" : "Bearish"
: "—";
return (
<div>
<h1 className="text-2xl font-bold mb-6">
<span className="text-accent-green">{ticker}</span> Technical Analysis
</h1>
<div className="atlas-page">
<SectionHeading level={1}>{ticker} Technical Analysis</SectionHeading>
{/* Period Selector */}
<div className="flex gap-2 mb-4">
@@ -169,10 +166,10 @@ export default function TechnicalPage() {
<button
key={p}
onClick={() => setPeriod(p)}
className={`px-3 py-1.5 rounded-md text-sm font-mono transition-all ${
className={`rounded-md px-3 py-1.5 text-sm font-mono transition-all ${
period === p
? "bg-accent-green text-bg-primary font-semibold"
: "bg-bg-card text-text-secondary hover:bg-bg-card/80"
? "bg-brand-navy text-white font-semibold"
: "bg-surface-raised text-text-secondary shadow-card hover:bg-surface-sunken"
}`}
>
{p.toUpperCase()}
@@ -181,43 +178,34 @@ export default function TechnicalPage() {
</div>
{/* Chart */}
<div className="bg-bg-card border border-border rounded-lg p-4 mb-6">
<ChartContainer title="Candlestick Chart" subtitle="OHLC with volume profile." className="mb-6">
<div ref={chartRef} className="w-full" style={{ minHeight: 400 }} />
</div>
</ChartContainer>
{/* Indicator Cards */}
{indicators && (
<div className="grid grid-cols-4 gap-3 mb-6">
<div className="bg-bg-card border border-border rounded-lg p-4">
<div className="text-text-muted text-xs mb-1">Current Price</div>
<div className="text-text-primary font-mono font-bold text-xl">${indicators.current_price?.toFixed(2)}</div>
</div>
<div className="bg-bg-card border border-border rounded-lg p-4">
<div className="text-text-muted text-xs mb-1">RSI (14)</div>
<div className={`font-mono font-bold text-xl ${rsiColor}`}>{indicators.rsi_14}</div>
<div className="text-text-muted text-xs mt-1">
{indicators.rsi_14 > 70 ? "Overbought" : indicators.rsi_14 < 30 ? "Oversold" : "Neutral"}
</div>
</div>
<div className="bg-bg-card border border-border rounded-lg p-4">
<div className="text-text-muted text-xs mb-1">MACD Signal</div>
<div className={`font-mono font-bold text-xl ${indicators.macd.histogram > 0 ? "text-accent-green" : "text-accent-red"}`}>
{macdSignal}
</div>
<div className="text-text-muted text-xs mt-1 font-mono">H: {indicators.macd.histogram.toFixed(4)}</div>
</div>
<div className="bg-bg-card border border-border rounded-lg p-4">
<div className="text-text-muted text-xs mb-1">ATR (14)</div>
<div className="text-text-primary font-mono font-bold text-xl">{indicators.atr_14}</div>
<div className="text-text-muted text-xs mt-1">Volatility</div>
</div>
<StatCard label="Current Price" value={`$${indicators.current_price?.toFixed(2)}`} />
<StatCard
label="RSI (14)"
value={indicators.rsi_14}
tone={indicators.rsi_14 > 70 ? "negative" : indicators.rsi_14 < 30 ? "positive" : "default"}
detail={indicators.rsi_14 > 70 ? "Overbought" : indicators.rsi_14 < 30 ? "Oversold" : "Neutral"}
/>
<StatCard
label="MACD Signal"
value={macdSignal}
tone={indicators.macd.histogram > 0 ? "positive" : "negative"}
detail={`H: ${indicators.macd.histogram.toFixed(4)}`}
/>
<StatCard label="ATR (14)" value={indicators.atr_14} detail="Volatility" />
</div>
)}
{/* Moving Averages & Bollinger */}
{indicators && (
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="bg-bg-card border border-border rounded-lg p-5">
<ChartContainer title="Moving Averages">
<h3 className="text-text-secondary text-sm font-semibold mb-3">Moving Averages</h3>
<div className="space-y-2">
{[
@@ -232,8 +220,8 @@ export default function TechnicalPage() {
<div className="flex items-center gap-3">
<span className="text-text-primary font-mono">{ma.value != null ? `$${ma.value.toFixed(2)}` : "—"}</span>
<span className={`text-xs font-semibold px-2 py-0.5 rounded ${
ma.signal === true ? "bg-accent-green/20 text-accent-green" :
ma.signal === false ? "bg-accent-red/20 text-accent-red" : "bg-bg-primary text-text-muted"
ma.signal === true ? "bg-fin-positive/15 text-fin-positive" :
ma.signal === false ? "bg-fin-negative/15 text-fin-negative" : "bg-surface-sunken text-text-muted"
}`}>
{ma.signal === true ? "ABOVE" : ma.signal === false ? "BELOW" : "N/A"}
</span>
@@ -241,22 +229,22 @@ export default function TechnicalPage() {
</div>
))}
</div>
</div>
</ChartContainer>
<div className="bg-bg-card border border-border rounded-lg p-5">
<ChartContainer title="Bollinger Bands" subtitle="20-period, 2 standard deviations.">
<h3 className="text-text-secondary text-sm font-semibold mb-3">Bollinger Bands (20, 2)</h3>
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-text-muted">Upper Band</span>
<span className="text-accent-red font-mono">${indicators.bollinger_bands.upper.toFixed(2)}</span>
<span className="font-mono text-fin-negative">${indicators.bollinger_bands.upper.toFixed(2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-text-muted">Middle Band</span>
<span className="text-accent-yellow font-mono">${indicators.bollinger_bands.middle.toFixed(2)}</span>
<span className="font-mono text-brand-gold">${indicators.bollinger_bands.middle.toFixed(2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-text-muted">Lower Band</span>
<span className="text-accent-green font-mono">${indicators.bollinger_bands.lower.toFixed(2)}</span>
<span className="font-mono text-fin-positive">${indicators.bollinger_bands.lower.toFixed(2)}</span>
</div>
<div className="flex justify-between text-sm border-t border-border pt-3">
<span className="text-text-muted">BB Width</span>
@@ -285,26 +273,26 @@ export default function TechnicalPage() {
</div>
<div className="flex justify-between text-sm">
<span className="text-text-muted">Histogram</span>
<span className={`font-mono ${indicators.macd.histogram > 0 ? "text-accent-green" : "text-accent-red"}`}>
<span className={`font-mono ${indicators.macd.histogram > 0 ? "text-fin-positive" : "text-fin-negative"}`}>
{indicators.macd.histogram.toFixed(4)}
</span>
</div>
</div>
</div>
</ChartContainer>
</div>
)}
{/* Fibonacci Levels */}
{fib && (
<div className="bg-bg-card border border-border rounded-lg p-5">
<ChartContainer title="Fibonacci Retracement">
<h3 className="text-text-secondary text-sm font-semibold mb-3">Fibonacci Retracement</h3>
<div className="grid grid-cols-7 gap-3">
{Object.entries(fib.levels).map(([level, price]) => {
const isNear = Math.abs(price - fib.current_price) / fib.current_price < 0.02;
return (
<div key={level} className={`text-center p-3 rounded-lg ${isNear ? "bg-accent-green/10 border border-accent-green" : "bg-bg-primary"}`}>
<div key={level} className={`rounded-lg p-3 text-center ${isNear ? "border border-brand-gold bg-brand-gold/10" : "bg-surface-sunken"}`}>
<div className="text-text-muted text-xs mb-1">{level}</div>
<div className={`font-mono text-sm font-semibold ${isNear ? "text-accent-green" : "text-text-primary"}`}>
<div className={`font-mono text-sm font-semibold ${isNear ? "text-brand-navy" : "text-text-primary"}`}>
${price.toFixed(2)}
</div>
</div>
@@ -314,7 +302,7 @@ export default function TechnicalPage() {
<div className="mt-3 text-text-muted text-xs font-mono">
52W Range: ${fib.low_52w.toFixed(2)} ${fib.high_52w.toFixed(2)} | Current: ${fib.current_price.toFixed(2)}
</div>
</div>
</ChartContainer>
)}
</div>
);
@@ -1,7 +1,20 @@
"use client";
import { useEffect, useState } from "react";
import { ErrorBanner } from "../components/ui/ErrorBanner";
import { LoadingPulse } from "../components/ui/LoadingPulse";
import { SectionHeading } from "../components/ui/SectionHeading";
import { useApi } from "../lib/use-api";
import { useTicker } from "../lib/use-ticker";
interface SmartDefaults {
wacc?: number;
terminal_growth?: number;
fcf_growth?: number;
}
interface OverviewResp {
asset_type?: string;
}
interface DCFInputs {
fcf?: number;
total_debt?: number;
@@ -51,47 +64,59 @@ type ValuationTab = "dcf" | "sensitivity" | "montecarlo" | "tornado" | "reverse"
export default function ValuationPage() {
const { ticker, initialized } = useTicker();
const [assetType, setAssetType] = useState<string>("equity");
const [inputs, setInputs] = useState<DCFInputs | null>(null);
const [consensus, setConsensus] = useState<Consensus | null>(null);
const [dcfResult, setDcfResult] = useState<DCFResult | null>(null);
const [wacc, setWacc] = useState(10);
const [terminalGrowth, setTerminalGrowth] = useState(2.5);
const [fcfGrowth, setFcfGrowth] = useState(8);
const [loading, setLoading] = useState(true);
const [dcfLoading, setDcfLoading] = useState(false);
const [activeTab, setActiveTab] = useState<ValuationTab>("dcf");
// Advanced models state
// Advanced models state (user-triggered; still raw fetch — will be split in P1-3)
const [dcfResult, setDcfResult] = useState<DCFResult | null>(null);
const [sensitivity, setSensitivity] = useState<SensitivityData | null>(null);
const [tornado, setTornado] = useState<TornadoItem[]>([]);
const [monteCarlo, setMonteCarlo] = useState<MonteCarloData | null>(null);
const [reverseDCF, setReverseDCF] = useState<{ implied_growth: number | null; current_price: number | null } | null>(null);
const [advLoading, setAdvLoading] = useState(false);
const [runtimeNotice, setRuntimeNotice] = useState<string>("");
// Initial data fetch via useApi — dedupes with /page and /research overview calls.
const inputsApi = useApi<DCFInputs>(initialized ? `/api/valuation/dcf-inputs/${ticker}` : null);
const consensusApi = useApi<Consensus>(initialized ? `/api/valuation/consensus/${ticker}` : null);
const defaultsApi = useApi<SmartDefaults>(initialized ? `/api/valuation/smart-defaults/${ticker}` : null);
const overviewApi = useApi<OverviewResp>(initialized ? `/api/market/overview/${ticker}` : null);
const inputs = inputsApi.data;
const consensus = consensusApi.data;
const assetType = overviewApi.data?.asset_type || "equity";
const loading = !initialized || inputsApi.loading || consensusApi.loading || defaultsApi.loading || overviewApi.loading;
// Apply smart defaults once loaded.
useEffect(() => {
const d = defaultsApi.data;
if (!d) return;
if (d.wacc) setWacc(d.wacc);
if (d.terminal_growth) setTerminalGrowth(d.terminal_growth);
if (d.fcf_growth) setFcfGrowth(d.fcf_growth);
}, [defaultsApi.data]);
// Reset downstream model results when ticker changes.
useEffect(() => {
if (!initialized) return;
setLoading(true);
setDcfResult(null);
setSensitivity(null);
setTornado([]);
setMonteCarlo(null);
setReverseDCF(null);
Promise.all([
fetch(`/api/valuation/dcf-inputs/${ticker}`).then((r) => r.ok ? r.json() : null),
fetch(`/api/valuation/consensus/${ticker}`).then((r) => r.ok ? r.json() : null),
fetch(`/api/valuation/smart-defaults/${ticker}`).then((r) => r.ok ? r.json() : null),
fetch(`/api/market/overview/${ticker}`).then((r) => r.ok ? r.json() : null),
]).then(([i, c, d, o]) => {
setInputs(i);
setConsensus(c);
setAssetType(o?.asset_type || "equity");
if (d?.wacc) setWacc(d.wacc);
if (d?.terminal_growth) setTerminalGrowth(d.terminal_growth);
if (d?.fcf_growth) setFcfGrowth(d.fcf_growth);
setLoading(false);
}).catch(() => setLoading(false));
}, [ticker, initialized]);
setRuntimeNotice("");
}, [ticker]);
// Derived notice: prioritise runtime errors (DCF failures), then load-time warnings.
const loadNotice =
runtimeNotice ||
(inputsApi.error
? "Could not load valuation inputs. Check the backend connection and retry."
: !loading && (!inputs?.fcf || !inputs?.shares)
? `DCF inputs are incomplete for ${ticker}. Smart defaults applied — review values before running models.`
: "");
async function runDCF() {
if (!inputs) return;
@@ -111,8 +136,15 @@ export default function ValuationPage() {
fcf_growth_rate: fcfGrowth / 100,
}),
});
if (res.ok) setDcfResult(await res.json());
} catch { /* */ }
if (res.ok) {
setDcfResult(await res.json());
setRuntimeNotice("");
} else {
setRuntimeNotice("DCF calculation failed. Verify inputs or retry shortly.");
}
} catch {
setRuntimeNotice("DCF request could not reach the backend.");
}
setDcfLoading(false);
}
@@ -167,19 +199,20 @@ export default function ValuationPage() {
});
if (res.ok) setReverseDCF(await res.json());
}
} catch { /* */ }
setRuntimeNotice("");
} catch {
setRuntimeNotice(`${tab} model request failed. Check the backend and retry.`);
}
setAdvLoading(false);
}
if (loading) return <div className="flex items-center justify-center h-64"><div className="text-accent-green animate-pulse font-mono">Loading...</div></div>;
if (loading) return <LoadingPulse label="Loading..." />;
if (assetType !== "equity") {
return (
<div>
<h1 className="text-2xl font-bold mb-6">
<span className="text-accent-green">{ticker}</span> Valuation
</h1>
<div className="bg-bg-card border border-border rounded-lg p-5">
<div className="atlas-page">
<SectionHeading level={1}>{ticker} Valuation</SectionHeading>
<div className="atlas-card p-5">
<h3 className="text-text-secondary text-sm font-semibold mb-3">
{assetType === "etf" ? "ETF Valuation Mode" : "Commodity Valuation Mode"}
</h3>
@@ -194,10 +227,10 @@ export default function ValuationPage() {
}
return (
<div>
<h1 className="text-2xl font-bold mb-6">
<span className="text-accent-green">{ticker}</span> Valuation
</h1>
<div className="atlas-page">
<SectionHeading level={1}>{ticker} Valuation</SectionHeading>
<ErrorBanner className="mb-4" variant="info" message={loadNotice || null} />
{/* Analyst Consensus */}
{consensus && (
@@ -522,14 +555,14 @@ function SliderInput({ label, value, onChange, min, max, step, suffix }: {
<div>
<div className="flex justify-between text-sm mb-2">
<span className="text-text-muted">{label}</span>
<span className="text-accent-green font-mono font-semibold">{value}{suffix}</span>
<span className="font-mono font-semibold text-brand-navy">{value}{suffix}</span>
</div>
<input
type="range"
min={min} max={max} step={step}
value={value}
onChange={(e) => onChange(parseFloat(e.target.value))}
className="w-full accent-[#00D4AA]"
className="w-full accent-brand-navy"
/>
</div>
);