feat: add Atlas Terminal — Next.js 14 + FastAPI full-stack migration

Complete migration from Streamlit to Next.js 14 App Router + FastAPI backend.

Frontend (Next.js 14):
- 10 pages: Overview, Research, Valuation, Technical, Markets, Earnings, News, Portfolio, Filings, Settings
- Terminal Noir dark theme with custom Tailwind config
- TradingView Lightweight Charts for candlestick/volume
- Valuation: DCF, Sensitivity Matrix, Monte Carlo, Tornado, Reverse DCF
- Financial Statements table with YoY growth badges and margin rows
- SEC EDGAR inline filing viewer with section tabs
- News split-view with iframe article embedding
- Technical Analysis with RSI, MACD, Bollinger, Fibonacci, Moving Averages
- Earnings beat/miss visualization
- AI Copilot chat panel with Gemini integration

Backend (FastAPI):
- 13 routers: market_data, financials, valuation, technical, earnings, insider, edgar, news, portfolio, analysis, chat, estimates, fx
- Services: DCF engine, Monte Carlo simulation, sensitivity analysis, risk metrics, SEC parser, technical indicators
- yfinance + yahooquery data sources with fallback pattern
- SQLite caching layer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shawnkim1997
2026-03-21 02:10:10 +00:00
co-authored by Claude Opus 4.6
parent 56a9561f71
commit b2acda81ee
111 changed files with 13883 additions and 270 deletions
@@ -0,0 +1,124 @@
"use client";
import { useState, useEffect, useRef } from "react";
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);
}, []);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
}, [messages]);
async function handleSend() {
if (!input.trim() || loading) return;
const userMsg = input.trim();
setInput("");
setMessages((prev) => [...prev, { role: "user", content: userMsg }]);
setLoading(true);
try {
const apiKey = localStorage.getItem("atlas_gemini_key") || "";
const res = await fetch("/api/analysis/strategy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ticker, question: userMsg, api_key: apiKey }),
});
if (res.ok) {
const data = await res.json();
const text = typeof data === "string" ? data : data.analysis || data.result || JSON.stringify(data);
setMessages((prev) => [...prev, { role: "assistant", content: text }]);
} else {
setMessages((prev) => [
...prev,
{ role: "assistant", content: "Settings에서 Gemini API Key를 설정해주세요." },
]);
}
} catch {
setMessages((prev) => [...prev, { role: "assistant", content: "연결 오류. 다시 시도해주세요." }]);
}
setLoading(false);
}
const suggestions = [
"Is this company undervalued?",
"Analyze the financial health",
"What are the key risks?",
];
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
</div>
<div ref={scrollRef} className="flex-1 p-4 overflow-y-auto flex flex-col gap-3">
{messages.length === 0 ? (
<div className="text-text-secondary text-sm">
<p>
Ask me anything about{" "}
<span className="text-accent-green font-semibold">{ticker}</span>.
</p>
<p className="mt-3 font-semibold text-text-primary">Try:</p>
<ul className="flex flex-col gap-1.5 mt-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"
>
{q}
</li>
))}
</ul>
</div>
) : (
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 ${
m.role === "user"
? "bg-bg-card text-text-primary self-end"
: "bg-accent-green/10 text-text-primary self-start"
}`}
>
{m.content}
</div>
))
)}
{loading && <div className="text-accent-green text-sm 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">
<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"
/>
<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"
>
Send
</button>
</div>
</div>
</aside>
);
}
@@ -0,0 +1,99 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState, useEffect } from "react";
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: "/earnings", label: "Earnings", icon: "📅" },
{ href: "/news", label: "News", icon: "📰" },
{ href: "/portfolio", label: "Portfolio", icon: "💼" },
{ href: "/filings", label: "Filings", icon: "📑" },
];
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);
}, []);
function handleSearch() {
const val = input.trim().toUpperCase();
if (val) {
setTickerLocal(val);
localStorage.setItem("atlas_active_ticker", val);
window.dispatchEvent(new CustomEvent("atlas-ticker-change", { detail: 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>
<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"
/>
</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>
</div>
{/* Navigation */}
<nav className="flex flex-col gap-1 mt-3">
{NAV_ITEMS.map((item) => {
const active = pathname === item.href;
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 ${
active
? "bg-accent-green/10 text-accent-green font-semibold border-accent-green"
: "text-text-secondary font-normal border-transparent hover:bg-bg-card"
}`}
>
<span>{item.icon}</span> {item.label}
</div>
</Link>
);
})}
<div className="border-t border-border my-2" />
<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 ${
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"
}`}
>
<span></span> Settings
</div>
</Link>
</nav>
</aside>
);
}
@@ -0,0 +1,63 @@
"use client";
import { useEffect, useState } from "react";
interface IndexData {
label: string;
symbol: string;
price: string;
change: string;
positive: boolean;
}
const INDICES = [
{ label: "S&P 500", symbol: "^GSPC" },
{ label: "NASDAQ", symbol: "^IXIC" },
{ label: "KOSPI", symbol: "^KS11" },
{ label: "BTC", symbol: "BTC-USD" },
];
export function TickerBar() {
const [data, setData] = useState<IndexData[]>(
INDICES.map((i) => ({ ...i, price: "—", change: "—", positive: true }))
);
useEffect(() => {
async function load() {
try {
const res = await fetch(`/api/market/indices`);
if (res.ok) {
const json = await res.json();
if (Array.isArray(json)) {
setData(json);
}
}
} catch {
// keep defaults
}
}
load();
const iv = setInterval(load, 60_000);
return () => clearInterval(iv);
}, []);
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>
</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">
<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"}>
{idx.change}
</span>
)}
</div>
))}
</div>
</header>
);
}
@@ -0,0 +1,170 @@
"use client";
import { useEffect, useState } from "react";
import { useTicker } from "../lib/use-ticker";
interface EarningsRecord {
date: string;
eps_actual: number | null;
eps_estimate: number | null;
surprise: number | null;
}
interface CalendarData {
next_earnings: string | null;
revenue_estimate: number | null;
eps_estimate: number | null;
}
interface QuarterlyData {
period: string;
revenue: number | null;
earnings: number | null;
}
export default function EarningsPage() {
const { ticker } = useTicker();
const [history, setHistory] = useState<EarningsRecord[]>([]);
const [calendar, setCalendar] = useState<CalendarData | null>(null);
const [quarterly, setQuarterly] = useState<QuarterlyData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
Promise.all([
fetch(`/api/earnings/${ticker}/history`).then((r) => r.ok ? r.json() : null),
fetch(`/api/earnings/${ticker}/calendar`).then((r) => r.ok ? r.json() : null),
fetch(`/api/earnings/${ticker}/quarterly`).then((r) => r.ok ? r.json() : null),
]).then(([h, c, q]) => {
setHistory(h?.history || []);
setCalendar(c);
setQuarterly(q?.quarterly || []);
setLoading(false);
}).catch(() => setLoading(false));
}, [ticker]);
if (loading) return <div className="flex items-center justify-center h-64"><div className="text-accent-green animate-pulse font-mono">Loading...</div></div>;
return (
<div>
<h1 className="text-2xl font-bold mb-6">
<span className="text-accent-green">{ticker}</span> Earnings
</h1>
{/* Next Earnings + Estimates */}
<div className="grid grid-cols-3 gap-3 mb-6">
<div className="bg-bg-card border border-accent-green rounded-lg p-4">
<div className="text-text-muted text-xs mb-1">Next Earnings Date</div>
<div className="text-accent-green font-mono font-bold text-lg">
{calendar?.next_earnings || "TBD"}
</div>
</div>
<div className="bg-bg-card border border-border rounded-lg p-4">
<div className="text-text-muted text-xs mb-1">EPS Estimate</div>
<div className="text-text-primary font-mono font-bold text-lg">
{calendar?.eps_estimate != null ? `$${calendar.eps_estimate.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">Revenue Estimate</div>
<div className="text-text-primary font-mono font-bold text-lg">
{calendar?.revenue_estimate != null ? `$${(calendar.revenue_estimate / 1e9).toFixed(2)}B` : "—"}
</div>
</div>
</div>
{/* EPS History — Beat/Miss Chart */}
<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">EPS History Beat/Miss</h3>
{history.length > 0 ? (
<div className="space-y-0">
{/* Visual bars */}
<div className="flex items-end gap-2 h-32 mb-4">
{history.map((h, i) => {
const beat = h.eps_actual != null && h.eps_estimate != null && h.eps_actual >= h.eps_estimate;
const barHeight = h.surprise != null ? Math.min(Math.abs(h.surprise) * 2, 100) : 20;
return (
<div key={i} className="flex-1 flex flex-col items-center justify-end h-full">
<div
className={`w-full rounded-t-sm ${beat ? "bg-accent-green" : "bg-accent-red"}`}
style={{ height: `${Math.max(barHeight, 8)}%` }}
/>
<div className="text-text-muted text-[10px] mt-1 font-mono">{h.date?.slice(0, 7)}</div>
</div>
);
})}
</div>
{/* Table */}
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left text-text-muted py-2 font-normal">Date</th>
<th className="text-right text-text-muted py-2 font-normal">EPS Estimate</th>
<th className="text-right text-text-muted py-2 font-normal">EPS Actual</th>
<th className="text-right text-text-muted py-2 font-normal">Surprise %</th>
<th className="text-right text-text-muted py-2 font-normal">Result</th>
</tr>
</thead>
<tbody>
{history.map((h, i) => {
const beat = h.eps_actual != null && h.eps_estimate != null && h.eps_actual >= h.eps_estimate;
return (
<tr key={i} className="border-b border-border/50">
<td className="py-2 text-text-primary font-mono">{h.date}</td>
<td className="py-2 text-text-secondary font-mono text-right">
{h.eps_estimate != null ? `$${h.eps_estimate.toFixed(2)}` : "—"}
</td>
<td className="py-2 text-text-primary font-mono text-right font-semibold">
{h.eps_actual != null ? `$${h.eps_actual.toFixed(2)}` : "—"}
</td>
<td className={`py-2 font-mono text-right ${h.surprise != null && h.surprise >= 0 ? "text-accent-green" : "text-accent-red"}`}>
{h.surprise != null ? `${h.surprise >= 0 ? "+" : ""}${h.surprise.toFixed(2)}%` : "—"}
</td>
<td className="py-2 text-right">
<span className={`text-xs font-semibold px-2 py-0.5 rounded ${
beat ? "bg-accent-green/20 text-accent-green" : "bg-accent-red/20 text-accent-red"
}`}>
{beat ? "BEAT" : "MISS"}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className="text-text-muted text-center py-8">No earnings history available</div>
)}
</div>
{/* Quarterly Revenue & Earnings */}
{quarterly.length > 0 && (
<div className="bg-bg-card border border-border rounded-lg p-5">
<h3 className="text-text-secondary text-sm font-semibold mb-4">Quarterly Revenue & Earnings</h3>
<div className="grid grid-cols-4 gap-3">
{quarterly.map((q, i) => (
<div key={i} className="bg-bg-primary rounded-lg p-4">
<div className="text-text-muted text-xs mb-2 font-mono">{q.period}</div>
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-text-muted">Revenue</span>
<span className="text-text-primary font-mono">
{q.revenue != null ? `$${(q.revenue / 1e9).toFixed(2)}B` : "—"}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-text-muted">Earnings</span>
<span className={`font-mono ${q.earnings != null && q.earnings >= 0 ? "text-accent-green" : "text-accent-red"}`}>
{q.earnings != null ? `$${(q.earnings / 1e9).toFixed(2)}B` : "—"}
</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,238 @@
"use client";
import { useState } from "react";
import { useTicker } from "../lib/use-ticker";
const SECTIONS = [
{ key: "item1a", label: "Item 1A: Risk Factors", short: "Risk Factors" },
{ key: "item7", label: "Item 7: MD&A", short: "MD&A" },
{ key: "item8", label: "Item 8: Financial Statements", short: "Financials" },
{ key: "item3", label: "Item 3: Legal Proceedings", short: "Legal" },
{ key: "item9a", label: "Item 9A: Controls & Procedures", short: "Controls" },
];
export default function FilingsPage() {
const { ticker } = useTicker();
const [activeSection, setActiveSection] = useState("item7");
const [sections, setSections] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const [email, setEmail] = useState("kimseonpil23@gmail.com");
const [aiSummary, setAiSummary] = useState<string>("");
const [aiLoading, setAiLoading] = useState(false);
const [error, setError] = useState<string>("");
async function loadFiling() {
setLoading(true);
setError("");
setSections({});
setAiSummary("");
try {
const res = await fetch(`/api/edgar/sections/${ticker}?email=${encodeURIComponent(email)}`);
if (res.ok) {
const data = await res.json();
setSections({
item1a: data.item1a || "",
item3: data.item3 || "",
item7: data.item7 || "",
item8: data.item8 || "",
item9a: data.item9a || "",
});
setLoaded(true);
} else {
const err = await res.json().catch(() => ({}));
setError(err.detail || "Failed to load SEC filing. Try a different ticker or check your connection.");
}
} catch {
setError("Connection error. Make sure the backend server is running.");
}
setLoading(false);
}
async function runAiSummary() {
const content = sections[activeSection];
if (!content) return;
const apiKey = localStorage.getItem("atlas_gemini_key") || "";
if (!apiKey) {
setAiSummary("Please set your Gemini API key in Settings first.");
return;
}
setAiLoading(true);
try {
const sectionLabel = SECTIONS.find((s) => s.key === activeSection)?.label || activeSection;
const res = await fetch("/api/analysis/mda", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ticker,
question: `Summarize and analyze this 10-K ${sectionLabel} section. Highlight key risks, trends, and important disclosures:\n\n${content.slice(0, 8000)}`,
api_key: apiKey,
}),
});
if (res.ok) {
const data = await res.json();
setAiSummary(typeof data === "string" ? data : data.analysis || JSON.stringify(data));
}
} catch {
setAiSummary("Error generating summary.");
}
setAiLoading(false);
}
const currentContent = sections[activeSection] || "";
const wordCount = currentContent ? currentContent.split(/\s+/).length : 0;
return (
<div>
<h1 className="text-2xl font-bold mb-4">
<span className="text-accent-green">{ticker}</span> SEC Filings
</h1>
{/* Load Section */}
{!loaded && (
<div className="bg-bg-card border border-border rounded-lg p-5 mb-6">
<h3 className="text-text-secondary text-sm font-semibold mb-3">10-K Annual Report</h3>
<p className="text-text-muted text-sm mb-4">
Downloads the latest 10-K filing from SEC EDGAR, parses and extracts individual sections for analysis.
</p>
<div className="flex items-center gap-3">
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="SEC EDGAR email (required)"
className="bg-bg-primary border border-border rounded-md px-3 py-2 text-text-primary outline-none text-sm w-72 focus:border-accent-green/50"
/>
<button
onClick={loadFiling}
disabled={loading || !email}
className="bg-accent-green text-bg-primary px-5 py-2 rounded-md text-sm font-semibold hover:opacity-90 disabled:opacity-50 transition-opacity"
>
{loading ? "Downloading & Parsing..." : "Load 10-K Filing"}
</button>
</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>
)}
{loading && (
<div className="mt-3 text-text-muted text-sm animate-pulse">
Downloading from SEC EDGAR... This may take 10-30 seconds for first download.
</div>
)}
</div>
)}
{/* Loaded Content */}
{loaded && (
<>
{/* Section Tabs */}
<div className="flex gap-1 mb-4 bg-bg-card border border-border rounded-lg p-1">
{SECTIONS.map((s) => {
const hasContent = !!sections[s.key];
return (
<button
key={s.key}
onClick={() => { setActiveSection(s.key); setAiSummary(""); }}
className={`flex-1 px-3 py-2 rounded-md text-xs font-mono transition-all ${
activeSection === s.key
? "bg-accent-green text-bg-primary font-semibold"
: hasContent
? "text-text-secondary hover:text-text-primary"
: "text-text-muted/50"
}`}
>
{s.short}
</button>
);
})}
</div>
{/* Section Header Bar */}
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-text-primary text-sm font-semibold">
{SECTIONS.find((s) => s.key === activeSection)?.label}
</h2>
{currentContent && (
<span className="text-text-muted text-xs font-mono">{wordCount.toLocaleString()} words</span>
)}
</div>
<div className="flex items-center gap-2">
{currentContent && (
<button
onClick={runAiSummary}
disabled={aiLoading}
className="bg-accent-blue text-white px-4 py-1.5 rounded-md text-xs font-semibold hover:opacity-90 disabled:opacity-50 transition-opacity"
>
{aiLoading ? "Analyzing..." : "AI Summary"}
</button>
)}
<button
onClick={loadFiling}
disabled={loading}
className="bg-bg-card border border-border text-text-secondary px-3 py-1.5 rounded-md text-xs hover:text-text-primary transition-colors"
>
Reload
</button>
</div>
</div>
{/* AI Summary */}
{aiSummary && (
<div className="bg-bg-card border border-accent-green/30 rounded-lg p-5 mb-4">
<div className="flex items-center gap-2 mb-3">
<span className="text-accent-green text-sm">🤖</span>
<h3 className="text-accent-green text-sm font-semibold">AI Analysis</h3>
</div>
<div className="text-text-primary text-sm leading-relaxed whitespace-pre-wrap">{aiSummary}</div>
</div>
)}
{/* Filing Content - Inline Display */}
{currentContent ? (
<div className="bg-bg-card border border-border rounded-lg overflow-hidden">
<div
className="p-6 overflow-y-auto text-text-primary text-sm leading-[1.8] font-sans"
style={{ maxHeight: "calc(100vh - 340px)" }}
>
{currentContent.split("\n").map((line, i) => {
const trimmed = line.trim();
if (!trimmed) return <div key={i} className="h-3" />;
// Detect headers (all-caps lines or lines starting with "Item")
const isHeader = /^(Item\s+\d|ITEM\s+\d)/i.test(trimmed) ||
(trimmed.length < 80 && trimmed === trimmed.toUpperCase() && /[A-Z]/.test(trimmed));
const isBullet = /^[•\-\*●]\s/.test(trimmed) || /^\d+\.\s/.test(trimmed);
if (isHeader) {
return (
<h3 key={i} className="text-accent-green font-semibold text-base mt-5 mb-2 border-b border-border/30 pb-1">
{trimmed}
</h3>
);
}
if (isBullet) {
return (
<div key={i} className="pl-4 py-0.5 text-text-secondary">
{trimmed}
</div>
);
}
return (
<p key={i} className="mb-1.5 text-text-primary/90">
{trimmed}
</p>
);
})}
</div>
</div>
) : (
<div className="bg-bg-card border border-border rounded-lg p-8 text-center text-text-muted">
No content available for this section.
</div>
)}
</>
)}
</div>
);
}
Binary file not shown.
@@ -0,0 +1,40 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
background: #0A0A0F;
color: #F3F4F6;
font-family: "Inter", system-ui, sans-serif;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #0A0A0F;
}
::-webkit-scrollbar-thumb {
background: #2A2A3A;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #3A3A4A;
}
input::placeholder {
color: #6B7280;
}
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import "./globals.css";
import { Sidebar } from "./components/sidebar";
import { TickerBar } from "./components/ticker-bar";
import { ChatPanel } from "./components/chat-panel";
export const metadata: Metadata = {
title: "ATLAS Terminal",
description: "Advanced Trading & Liquidity Analysis System",
};
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>
<TickerBar />
<div className="flex pt-[52px] min-h-screen">
<Sidebar />
<main className="flex-1 ml-[260px] mr-[380px] p-7 bg-bg-primary min-h-[calc(100vh-52px)] transition-all duration-200">
{children}
</main>
<ChatPanel />
</div>
</body>
</html>
);
}
@@ -0,0 +1,17 @@
const BASE = "/api";
export async function apiFetch<T = unknown>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
});
if (!res.ok) throw new Error(`API ${res.status}: ${res.statusText}`);
return res.json();
}
export async function apiPost<T = unknown>(path: string, body: unknown): Promise<T> {
return apiFetch<T>(path, {
method: "POST",
body: JSON.stringify(body),
});
}
@@ -0,0 +1,34 @@
"use client";
import { useState, useEffect, useCallback } from "react";
const DEFAULT_TICKER = "AAPL";
const STORAGE_KEY = "atlas_active_ticker";
const EVENT_NAME = "atlas-ticker-change";
function getInitialTicker(): string {
if (typeof window === "undefined") return DEFAULT_TICKER;
return localStorage.getItem(STORAGE_KEY) || DEFAULT_TICKER;
}
export function useTicker() {
const [ticker, setTickerState] = useState(getInitialTicker);
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail) setTickerState(detail);
};
window.addEventListener(EVENT_NAME, handler);
return () => window.removeEventListener(EVENT_NAME, handler);
}, []);
const setTicker = useCallback((val: string) => {
const upper = val.trim().toUpperCase();
if (!upper) return;
setTickerState(upper);
localStorage.setItem(STORAGE_KEY, upper);
window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: upper }));
}, []);
return { ticker, setTicker };
}
@@ -0,0 +1,334 @@
"use client";
import { useEffect, useState } from "react";
import { useTicker } from "../lib/use-ticker";
type StatementType = "income_statement" | "balance_sheet" | "cash_flow";
interface FinancialStatements {
income_statement?: Record<string, unknown>[];
balance_sheet?: Record<string, unknown>[];
cash_flow?: Record<string, unknown>[];
}
const TABS: { key: StatementType; label: string }[] = [
{ key: "income_statement", label: "Income Statement" },
{ key: "balance_sheet", label: "Balance Sheet" },
{ key: "cash_flow", label: "Cash Flow" },
];
// Define the row structure for each statement type
interface RowDef {
key: string;
label: string;
isHeader?: boolean;
isGrowth?: boolean;
indent?: boolean;
bold?: boolean;
}
// Keys support both yahooquery (CamelCase) and yfinance (Spaced) formats
const INCOME_ROWS: RowDef[] = [
{ key: "Total Revenue|TotalRevenue|Operating Revenue|OperatingRevenue", label: "Total Revenue", bold: true },
{ key: "_revenue_yoy", label: "YoY Growth (%)", isGrowth: true },
{ key: "Cost Of Revenue|CostOfRevenue|Reconciled Cost Of Revenue", label: "Cost of Revenue", indent: true },
{ key: "Gross Profit|GrossProfit", label: "Gross Profit", bold: true },
{ key: "_grossprofit_yoy", label: "YoY Growth (%)", isGrowth: true },
{ key: "_GrossMargin", label: "Gross Margin (%)", isGrowth: true },
{ key: "Selling General And Administration|SellingGeneralAndAdministration", label: "SG&A Expenses", indent: true },
{ key: "Research And Development|ResearchAndDevelopment", label: "R&D Expenses", indent: true },
{ key: "Operating Expense|OperatingExpense|Total Expenses", label: "Total Operating Expenses", indent: true },
{ key: "Operating Income|OperatingIncome|EBIT", label: "Operating Income (EBIT)", bold: true },
{ key: "_operatingincome_yoy", label: "YoY Growth (%)", isGrowth: true },
{ key: "_OperatingMargin", label: "Operating Margin (%)", isGrowth: true },
{ key: "Interest Expense|InterestExpense", label: "Interest Expense", indent: true },
{ key: "Other Income Expense|OtherIncomeExpense", label: "Other Income/Expense", indent: true },
{ key: "Pretax Income|PretaxIncome", label: "Income Before Tax", bold: true },
{ key: "_pretaxincome_yoy", label: "YoY Growth (%)", isGrowth: true },
{ key: "Tax Provision|TaxProvision", label: "Income Tax Expense", indent: true },
{ key: "_TaxRate", label: "Effective Tax Rate (%)", isGrowth: true },
{ key: "Net Income|NetIncome", label: "Net Income", bold: true },
{ key: "_netincome_yoy", label: "YoY Growth (%)", isGrowth: true },
{ key: "_NetMargin", label: "Net Margin (%)", isGrowth: true },
{ key: "EBITDA|Normalized EBITDA|NormalizedEBITDA", label: "EBITDA", bold: true },
{ key: "Basic EPS|BasicEPS", label: "Basic EPS" },
{ key: "Diluted EPS|DilutedEPS", label: "Diluted EPS" },
{ key: "Basic Average Shares|BasicAverageShares", label: "Shares Outstanding (Basic)" },
];
const BALANCE_ROWS: RowDef[] = [
{ key: "Total Assets|TotalAssets", label: "Total Assets", bold: true },
{ key: "Current Assets|CurrentAssets", label: "Current Assets", bold: true },
{ key: "Cash And Cash Equivalents|CashAndCashEquivalents", label: "Cash & Equivalents", indent: true },
{ key: "Cash Cash Equivalents And Short Term Investments|CashCashEquivalentsAndShortTermInvestments", label: "Cash & Short-term Investments", indent: true },
{ key: "Receivables", label: "Receivables", indent: true },
{ key: "Inventory", label: "Inventory", indent: true },
{ key: "Other Current Assets|OtherCurrentAssets", label: "Other Current Assets", indent: true },
{ key: "Total Non Current Assets|TotalNonCurrentAssets", label: "Non-Current Assets", bold: true },
{ key: "Net PPE|NetPPE", label: "PP&E (Net)", indent: true },
{ key: "Goodwill And Other Intangible Assets|GoodwillAndOtherIntangibleAssets|Goodwill", label: "Goodwill & Intangibles", indent: true },
{ key: "Total Liabilities Net Minority Interest|TotalLiabilitiesNetMinorityInterest", label: "Total Liabilities", bold: true },
{ key: "Current Liabilities|CurrentLiabilities", label: "Current Liabilities", bold: true },
{ key: "Current Debt|CurrentDebt|Current Debt And Capital Lease Obligation", label: "Current Debt", indent: true },
{ key: "Accounts Payable|AccountsPayable", label: "Accounts Payable", indent: true },
{ key: "Total Non Current Liabilities Net Minority Interest|TotalNonCurrentLiabilitiesNetMinorityInterest", label: "Non-Current Liabilities", bold: true },
{ key: "Long Term Debt|LongTermDebt|Long Term Debt And Capital Lease Obligation", label: "Long-term Debt", indent: true },
{ key: "Stockholders Equity|StockholdersEquity|Total Equity Gross Minority Interest", label: "Stockholders' Equity", bold: true },
{ key: "Retained Earnings|RetainedEarnings", label: "Retained Earnings", indent: true },
{ key: "Common Stock|CommonStock|Common Stock Equity", label: "Common Stock Equity", indent: true },
];
const CASHFLOW_ROWS: RowDef[] = [
{ key: "Operating Cash Flow|OperatingCashFlow", label: "Operating Cash Flow", bold: true },
{ key: "Net Income|Net Income From Continuing Operations|NetIncome", label: "Net Income", indent: true },
{ key: "Depreciation And Amortization|DepreciationAndAmortization|Depreciation Amortization Depletion", label: "D&A", indent: true },
{ key: "Change In Working Capital|ChangeInWorkingCapital", label: "Change in Working Capital", indent: true },
{ key: "Stock Based Compensation|StockBasedCompensation", label: "Stock-based Compensation", indent: true },
{ key: "Investing Cash Flow|InvestingCashFlow", label: "Investing Cash Flow", bold: true },
{ key: "Capital Expenditure|CapitalExpenditure|Purchase Of PPE", label: "Capital Expenditure", indent: true },
{ key: "Purchase Of Investment|PurchaseOfInvestment", label: "Purchases of Investments", indent: true },
{ key: "Sale Of Investment|SaleOfInvestment", label: "Sales of Investments", indent: true },
{ key: "Financing Cash Flow|FinancingCashFlow", label: "Financing Cash Flow", bold: true },
{ key: "Common Stock Issuance|CommonStockIssuance", label: "Stock Issuance", indent: true },
{ key: "Repurchase Of Capital Stock|RepurchaseOfCapitalStock", label: "Share Buybacks", indent: true },
{ key: "Common Stock Dividend Paid|CommonStockDividendPaid|Cash Dividends Paid", label: "Dividends Paid", indent: true },
{ key: "Issuance Of Debt|DebtIssuance|Long Term Debt Issuance", label: "Debt Issuance", indent: true },
{ key: "Repayment Of Debt|DebtRepayment|Long Term Debt Payments", label: "Debt Repayment", indent: true },
{ key: "Free Cash Flow|FreeCashFlow", label: "Free Cash Flow", bold: true },
{ key: "End Cash Position|EndCashPosition|Changes In Cash", label: "End Cash Position", bold: true },
];
const ROW_MAP: Record<StatementType, RowDef[]> = {
income_statement: INCOME_ROWS,
balance_sheet: BALANCE_ROWS,
cash_flow: CASHFLOW_ROWS,
};
export default function MarketsPage() {
const { ticker } = useTicker();
const [data, setData] = useState<FinancialStatements | null>(null);
const [tab, setTab] = useState<StatementType>("income_statement");
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`/api/financials/${ticker}/statements`)
.then((r) => (r.ok ? r.json() : null))
.then((d) => {
setData(d);
setLoading(false);
})
.catch(() => setLoading(false));
}, [ticker]);
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 rows = data?.[tab] || [];
const rowDefs = ROW_MAP[tab];
// Extract period dates (columns) - skip first period if all nulls
const periods = rows
.filter((r) => {
const vals = Object.entries(r).filter(([k]) => !["period", "asOfDate", "periodType", "currencyCode"].includes(k));
return vals.some(([, v]) => v != null);
})
.map((r) => ({
date: String(r.asOfDate || "").slice(0, 10),
periodType: String(r.periodType || ""),
data: r,
}))
.reverse(); // most recent first
// Compute derived values
function getValue(periodData: Record<string, unknown>, key: string): number | null {
if (key.startsWith("_")) return null; // computed below
// Support pipe-separated key alternatives
const keys = key.split("|");
for (const k of keys) {
const v = periodData[k.trim()];
if (v != null && typeof v === "number") return v;
}
return null;
}
function getComputedValue(periodData: Record<string, unknown>, key: string, prevPeriodData?: Record<string, unknown>): number | null {
const rev = getValue(periodData, "Total Revenue|TotalRevenue|Operating Revenue|OperatingRevenue");
if (key === "_GrossMargin") {
const gp = getValue(periodData, "Gross Profit|GrossProfit");
return rev && gp ? (gp / rev) * 100 : null;
}
if (key === "_OperatingMargin") {
const oi = getValue(periodData, "Operating Income|OperatingIncome|EBIT");
return rev && oi ? (oi / rev) * 100 : null;
}
if (key === "_NetMargin") {
const ni = getValue(periodData, "Net Income|NetIncome");
return rev && ni ? (ni / rev) * 100 : null;
}
if (key === "_TaxRate") {
const tax = getValue(periodData, "Tax Provision|TaxProvision");
const pretax = getValue(periodData, "Pretax Income|PretaxIncome");
return pretax && tax ? (tax / pretax) * 100 : null;
}
// YoY growth — find the matching row definition to get the key alternatives
if (key.endsWith("_yoy") && prevPeriodData) {
const yoyMap: Record<string, string> = {
"_revenue_yoy": "Total Revenue|TotalRevenue|Operating Revenue|OperatingRevenue",
"_grossprofit_yoy": "Gross Profit|GrossProfit",
"_operatingincome_yoy": "Operating Income|OperatingIncome|EBIT",
"_pretaxincome_yoy": "Pretax Income|PretaxIncome",
"_netincome_yoy": "Net Income|NetIncome",
};
const multiKey = yoyMap[key];
if (multiKey) {
const curr = getValue(periodData, multiKey);
const prev = getValue(prevPeriodData, multiKey);
if (curr != null && prev != null && prev !== 0) {
return ((curr - prev) / Math.abs(prev)) * 100;
}
}
}
return null;
}
function getCellValue(rowDef: RowDef, periodIdx: number): number | null {
if (periods.length === 0) return null;
const pd = periods[periodIdx]?.data;
if (!pd) return null;
if (rowDef.key.startsWith("_")) {
const prevPd = periodIdx < periods.length - 1 ? periods[periodIdx + 1]?.data : undefined;
return getComputedValue(pd, rowDef.key, prevPd);
}
return getValue(pd, rowDef.key);
}
function formatCell(val: number | null, rowDef: RowDef): string {
if (val == null) return "—";
if (rowDef.isGrowth) return `${val >= 0 ? "" : ""}${val.toFixed(2)}%`;
if (rowDef.key === "BasicEPS" || rowDef.key === "DilutedEPS") return val.toFixed(2);
if (Math.abs(val) >= 1e9) return `${(val / 1e6).toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
if (Math.abs(val) >= 1e6) return `${(val / 1e6).toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
return val.toLocaleString(undefined, { maximumFractionDigits: 2 });
}
// Check if row has any data
function rowHasData(rowDef: RowDef): boolean {
return periods.some((_, i) => getCellValue(rowDef, i) != null);
}
const filteredRows = rowDefs.filter(rowHasData);
return (
<div>
<h1 className="text-2xl font-bold mb-4">
<span className="text-accent-green">{ticker}</span> Financial Statements
</h1>
{/* Unit Note */}
<div className="text-text-muted text-xs mb-3 font-mono">Unit: Millions USD (except per-share data)</div>
{/* Tabs */}
<div className="flex gap-1 mb-4 bg-bg-card rounded-lg p-1 w-fit">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
tab === t.key ? "bg-accent-green text-bg-primary" : "text-text-secondary hover:text-text-primary"
}`}
>
{t.label}
</button>
))}
</div>
{/* Table */}
{periods.length > 0 ? (
<div className="bg-bg-card border border-border rounded-lg overflow-x-auto">
<table className="w-full text-xs">
{/* Column Headers - Period Dates */}
<thead>
<tr className="border-b border-border bg-bg-primary/50">
<th className="text-left px-4 py-3 text-text-muted font-semibold sticky left-0 bg-bg-card min-w-[220px] z-10">
{tab === "income_statement" ? "Income Statement" : tab === "balance_sheet" ? "Balance Sheet" : "Cash Flow Statement"}
</th>
{periods.map((p, i) => (
<th key={i} className="text-right px-4 py-3 text-text-muted font-semibold whitespace-nowrap min-w-[110px]">
<div className="text-text-secondary">{p.date.slice(0, 4)}</div>
<div className="text-text-muted text-[10px]">{p.date}</div>
</th>
))}
</tr>
</thead>
<tbody>
{filteredRows.map((rowDef, ri) => {
const isGrowth = rowDef.isGrowth;
return (
<tr
key={rowDef.key}
className={`border-b border-border/30 ${
isGrowth ? "bg-bg-primary/30" : rowDef.bold ? "bg-bg-primary/10" : ""
} hover:bg-bg-card/80 transition-colors`}
>
{/* Row Label */}
<td
className={`px-4 py-2 sticky left-0 bg-bg-card z-10 ${
rowDef.bold ? "font-semibold text-text-primary" : isGrowth ? "text-text-muted italic text-[11px]" : "text-text-secondary"
} ${rowDef.indent ? "pl-8" : ""} ${isGrowth ? "pl-8" : ""}`}
>
{isGrowth ? `${rowDef.label}` : rowDef.label}
</td>
{/* Period Values */}
{periods.map((_, pi) => {
const val = getCellValue(rowDef, pi);
const formatted = formatCell(val, rowDef);
let colorClass = "text-text-primary";
if (isGrowth && val != null) {
if (val > 0) colorClass = "text-accent-green";
else if (val < 0) colorClass = "text-accent-red";
else colorClass = "text-text-muted";
} else if (val != null && val < 0 && !isGrowth) {
colorClass = "text-accent-red";
}
return (
<td
key={pi}
className={`px-4 py-2 text-right font-mono whitespace-nowrap ${colorClass} ${
rowDef.bold && !isGrowth ? "font-semibold" : ""
} ${isGrowth ? "text-[11px]" : ""}`}
>
{isGrowth && val != null ? (
<span
className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold ${
val > 0 ? "bg-accent-green/15 text-accent-green" : val < 0 ? "bg-accent-red/15 text-accent-red" : "bg-bg-primary text-text-muted"
}`}
>
{formatted}
</span>
) : (
formatted
)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className="bg-bg-card border border-border rounded-lg p-8 text-center text-text-muted">
No financial data available for {ticker}
</div>
)}
</div>
);
}
@@ -0,0 +1,142 @@
"use client";
import { useEffect, useState } from "react";
import { useTicker } from "../lib/use-ticker";
interface NewsItem {
title: string;
source: string;
url: string;
published_at: string;
summary: string;
}
export default function NewsPage() {
const { ticker } = useTicker();
const [news, setNews] = useState<NewsItem[]>([]);
const [loading, setLoading] = useState(true);
const [selectedIdx, setSelectedIdx] = useState<number | null>(null);
useEffect(() => {
setLoading(true);
setSelectedIdx(null);
fetch(`/api/news/${ticker}`)
.then((r) => (r.ok ? r.json() : []))
.then((data) => {
setNews(Array.isArray(data) ? data : []);
setLoading(false);
})
.catch(() => setLoading(false));
}, [ticker]);
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 selectedItem = selectedIdx !== null ? news[selectedIdx] : null;
return (
<div>
<h1 className="text-2xl font-bold mb-4">
<span className="text-accent-green">{ticker}</span> News Feed
</h1>
<div className="text-text-muted text-sm mb-4">
{news.length} articles from Finviz & Google News
</div>
<div className="flex gap-4" style={{ height: "calc(100vh - 200px)" }}>
{/* Article List */}
<div
className={`${
selectedItem ? "w-[340px] shrink-0" : "w-full"
} overflow-y-auto transition-all duration-200`}
>
<div className="space-y-2">
{news.length > 0 ? (
news.map((item, i) => (
<div
key={i}
onClick={() => setSelectedIdx(i)}
className={`cursor-pointer rounded-lg p-3 transition-all border ${
selectedIdx === i
? "bg-accent-green/10 border-accent-green/50"
: "bg-bg-card border-border hover:border-accent-green/30"
}`}
>
<h3
className={`text-sm font-semibold leading-snug ${
selectedIdx === i ? "text-accent-green" : "text-text-primary"
}`}
>
{item.title}
</h3>
<div className="flex items-center gap-2 mt-2">
{item.source && (
<span className="text-[10px] px-1.5 py-0.5 bg-accent-blue/10 text-accent-blue rounded font-mono">
{item.source}
</span>
)}
<span className="text-text-muted text-[10px] font-mono">
{item.published_at}
</span>
</div>
</div>
))
) : (
<div className="text-text-muted text-center py-12">No news articles found</div>
)}
</div>
</div>
{/* Article Content - Right Panel */}
{selectedItem && (
<div className="flex-1 flex flex-col bg-bg-card border border-border rounded-lg overflow-hidden min-w-0">
{/* Header */}
<div className="px-5 py-4 border-b border-border bg-bg-primary/50 shrink-0">
<h2 className="text-base font-bold text-text-primary leading-snug mb-2">
{selectedItem.title}
</h2>
<div className="flex items-center gap-3">
{selectedItem.source && (
<span className="text-xs px-2 py-0.5 bg-accent-blue/10 text-accent-blue rounded font-mono">
{selectedItem.source}
</span>
)}
<span className="text-text-muted text-xs font-mono">
{selectedItem.published_at}
</span>
<a
href={selectedItem.url}
target="_blank"
rel="noopener noreferrer"
className="ml-auto text-xs px-3 py-1 bg-accent-green text-bg-primary rounded-md font-semibold hover:opacity-90 transition-opacity"
>
Open Original
</a>
<button
onClick={() => setSelectedIdx(null)}
className="text-text-muted hover:text-text-primary transition-colors text-base"
>
</button>
</div>
</div>
{/* Article Embed */}
<div className="flex-1 relative bg-white">
<iframe
src={selectedItem.url}
className="w-full h-full border-0"
sandbox="allow-scripts allow-same-origin allow-popups"
referrerPolicy="no-referrer"
title={selectedItem.title}
/>
</div>
</div>
)}
</div>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
"use client";
import { useEffect, useState } from "react";
import { useTicker } from "./lib/use-ticker";
interface HealthData {
dupont?: { roe?: number; npm?: number; asset_turnover?: number; equity_multiplier?: number };
altman_z?: number;
red_flags?: string[];
}
interface SectorData {
sector?: string;
industry?: string;
market_cap?: number;
pe_ratio?: number;
dividend_yield?: number;
beta?: number;
fifty_two_week_high?: number;
fifty_two_week_low?: number;
current_price?: number;
}
export default function OverviewPage() {
const { ticker } = useTicker();
const [sector, setSector] = useState<SectorData | null>(null);
const [health, setHealth] = useState<HealthData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
Promise.all([
fetch(`/api/market/sector/${ticker}`).then((r) => r.ok ? r.json() : null),
fetch(`/api/market/health/${ticker}`).then((r) => r.ok ? r.json() : null),
]).then(([s, h]) => {
setSector(s);
setHealth(h);
setLoading(false);
}).catch(() => setLoading(false));
}, [ticker]);
if (loading) return <LoadingState />;
const metrics = [
{ label: "Sector", value: sector?.sector || "—" },
{ label: "Industry", value: sector?.industry || "—" },
{ label: "Market Cap", value: sector?.market_cap ? `$${(sector.market_cap / 1e9).toFixed(1)}B` : "—" },
{ label: "P/E Ratio", value: sector?.pe_ratio?.toFixed(1) || "—" },
{ label: "Beta", value: sector?.beta?.toFixed(2) || "—" },
{ label: "Div Yield", value: sector?.dividend_yield ? `${sector.dividend_yield.toFixed(2)}%` : "—" },
{ label: "52W High", value: sector?.fifty_two_week_high ? `$${sector.fifty_two_week_high.toFixed(2)}` : "—" },
{ label: "52W Low", value: sector?.fifty_two_week_low ? `$${sector.fifty_two_week_low.toFixed(2)}` : "—" },
];
const zScore = health?.altman_z;
const zColor = zScore && zScore > 2.99 ? "text-accent-green" : zScore && zScore > 1.81 ? "text-accent-yellow" : "text-accent-red";
return (
<div>
<h1 className="text-2xl font-bold mb-1">
<span className="text-accent-green">{ticker}</span> Overview
</h1>
{sector?.current_price && (
<p className="text-3xl font-mono font-bold text-text-primary mb-6">
${sector.current_price.toFixed(2)}
</p>
)}
{/* Key Metrics Grid */}
<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>
))}
</div>
{/* Health Section */}
<div className="grid grid-cols-2 gap-4">
{/* Altman Z-Score */}
<div className="bg-bg-card border border-border rounded-lg p-5">
<h3 className="text-text-secondary text-sm font-semibold mb-3">Altman Z-Score</h3>
<div className={`text-4xl font-mono font-bold ${zColor}`}>
{zScore?.toFixed(2) || "—"}
</div>
<div className="text-text-muted text-xs mt-2">
{zScore && zScore > 2.99 ? "Safe Zone" : zScore && zScore > 1.81 ? "Grey Zone" : "Distress Zone"}
</div>
</div>
{/* DuPont Analysis */}
<div className="bg-bg-card border border-border rounded-lg p-5">
<h3 className="text-text-secondary text-sm font-semibold mb-3">DuPont Analysis</h3>
{health?.dupont ? (
<div className="space-y-2">
{[
{ label: "ROE", value: health.dupont.roe },
{ label: "Net Profit Margin", value: health.dupont.npm },
{ label: "Asset Turnover", value: health.dupont.asset_turnover },
{ label: "Equity Multiplier", value: health.dupont.equity_multiplier },
].map((d) => (
<div key={d.label} className="flex justify-between items-center">
<span className="text-text-muted text-sm">{d.label}</span>
<span className="text-text-primary font-mono font-semibold">
{d.value?.toFixed(2) || "—"}
</span>
</div>
))}
</div>
) : (
<div className="text-text-muted">No data</div>
)}
</div>
</div>
{/* Red Flags */}
{health?.red_flags && health.red_flags.length > 0 && (
<div className="mt-4 bg-bg-card border border-accent-red/30 rounded-lg p-5">
<h3 className="text-accent-red text-sm font-semibold mb-3">Red Flags</h3>
<ul className="space-y-1.5">
{health.red_flags.map((f, i) => (
<li key={i} className="text-text-secondary text-sm flex items-start gap-2">
<span className="text-accent-red mt-0.5"></span> {f}
</li>
))}
</ul>
</div>
)}
</div>
);
}
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>
);
}
@@ -0,0 +1,166 @@
"use client";
import { useState, useEffect } from "react";
interface Position {
id?: string;
ticker: string;
company_name?: string;
quantity: number;
avg_price: number;
current_price?: number;
market_value?: number;
pnl?: number;
pnl_pct?: number;
}
export default function PortfolioPage() {
const [positions, setPositions] = useState<Position[]>([]);
const [form, setForm] = useState({ ticker: "", quantity: "", avg_price: "" });
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchPortfolio();
}, []);
async function fetchPortfolio() {
setLoading(true);
try {
const res = await fetch("/api/portfolio/summary");
if (res.ok) {
const data = await res.json();
setPositions(data.positions || []);
} else {
// fallback: try basic positions list
const res2 = await fetch("/api/portfolio/positions");
if (res2.ok) {
const data2 = await res2.json();
setPositions(Array.isArray(data2) ? data2 : []);
}
}
} catch {
// Portfolio may not have data yet
}
setLoading(false);
}
async function addPosition() {
const t = form.ticker.trim().toUpperCase();
const q = parseFloat(form.quantity);
const p = parseFloat(form.avg_price);
if (!t || isNaN(q) || isNaN(p)) return;
try {
const res = await fetch("/api/portfolio/positions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ticker: t, quantity: q, avg_price: p }),
});
if (res.ok) {
setForm({ ticker: "", quantity: "", avg_price: "" });
fetchPortfolio();
}
} catch {
// error
}
}
const totalValue = positions.reduce((s, p) => s + (p.market_value || p.quantity * (p.current_price || p.avg_price)), 0);
const totalCost = positions.reduce((s, p) => s + p.quantity * p.avg_price, 0);
const totalGL = totalValue - totalCost;
return (
<div>
<h1 className="text-2xl font-bold mb-6">Portfolio</h1>
{/* Summary */}
<div className="grid grid-cols-3 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">Total Value</div>
<div className="text-text-primary font-mono font-bold text-xl">${totalValue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div>
</div>
<div className="bg-bg-card border border-border rounded-lg p-4">
<div className="text-text-muted text-xs mb-1">Total Cost</div>
<div className="text-text-primary font-mono font-bold text-xl">${totalCost.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div>
</div>
<div className="bg-bg-card border border-border rounded-lg p-4">
<div className="text-text-muted text-xs mb-1">Total P&L</div>
<div className={`font-mono font-bold text-xl ${totalGL >= 0 ? "text-accent-green" : "text-accent-red"}`}>
{totalGL >= 0 ? "+" : ""}${totalGL.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
</div>
{/* Add Position */}
<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">Add Position</h3>
<div className="flex gap-3">
<input
value={form.ticker}
onChange={(e) => setForm({ ...form, ticker: e.target.value })}
placeholder="Ticker"
className="bg-bg-primary border border-border rounded-md px-3 py-2 text-text-primary outline-none w-32"
/>
<input
value={form.quantity}
onChange={(e) => setForm({ ...form, quantity: e.target.value })}
placeholder="Quantity"
type="number"
className="bg-bg-primary border border-border rounded-md px-3 py-2 text-text-primary outline-none w-32"
/>
<input
value={form.avg_price}
onChange={(e) => setForm({ ...form, avg_price: e.target.value })}
placeholder="Avg Price"
type="number"
className="bg-bg-primary border border-border rounded-md px-3 py-2 text-text-primary outline-none w-32"
/>
<button onClick={addPosition} className="bg-accent-green text-bg-primary px-5 py-2 rounded-md font-semibold hover:opacity-90 transition-opacity">
Add
</button>
</div>
</div>
{/* Positions Table */}
{positions.length > 0 ? (
<div className="bg-bg-card border border-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
{["Ticker", "Qty", "Avg Price", "Price", "Value", "P&L", "P&L %"].map((h) => (
<th key={h} className="text-left px-4 py-3 text-text-muted font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{positions.map((p, i) => {
const price = p.current_price || p.avg_price;
const value = p.market_value || p.quantity * price;
const gl = p.pnl ?? (value - p.quantity * p.avg_price);
const glPct = p.pnl_pct ?? ((price / p.avg_price - 1) * 100);
return (
<tr key={p.id || `${p.ticker}-${i}`} className="border-b border-border/50 hover:bg-bg-hover/30">
<td className="px-4 py-2.5 font-mono font-semibold text-accent-green">{p.ticker}</td>
<td className="px-4 py-2.5 font-mono text-text-primary">{p.quantity}</td>
<td className="px-4 py-2.5 font-mono text-text-primary">${p.avg_price.toFixed(2)}</td>
<td className="px-4 py-2.5 font-mono text-text-primary">${price.toFixed(2)}</td>
<td className="px-4 py-2.5 font-mono text-text-primary">${value.toLocaleString(undefined, { minimumFractionDigits: 2 })}</td>
<td className={`px-4 py-2.5 font-mono ${gl >= 0 ? "text-accent-green" : "text-accent-red"}`}>
{gl >= 0 ? "+" : ""}${gl.toFixed(2)}
</td>
<td className={`px-4 py-2.5 font-mono ${glPct >= 0 ? "text-accent-green" : "text-accent-red"}`}>
{glPct >= 0 ? "+" : ""}{glPct.toFixed(1)}%
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : !loading ? (
<div className="bg-bg-card border border-border rounded-lg p-8 text-center text-text-muted">
No positions yet. Add your first position above.
</div>
) : null}
</div>
);
}
@@ -0,0 +1,151 @@
"use client";
import { useEffect, useState } from "react";
import { useTicker } from "../lib/use-ticker";
interface PiotroskiData {
score?: number;
details?: Record<string, { pass: boolean; value: number }>;
}
interface RadarData {
roe?: number;
roa?: number;
gross_margin?: number;
current_ratio?: number;
revenue_growth?: number;
}
export default function ResearchPage() {
const { ticker } = useTicker();
const [piotroski, setPiotroski] = useState<PiotroskiData | null>(null);
const [radar, setRadar] = useState<RadarData | null>(null);
const [aiAnalysis, setAiAnalysis] = useState<string>("");
const [aiLoading, setAiLoading] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
Promise.all([
fetch(`/api/market/piotroski/${ticker}`).then((r) => r.ok ? r.json() : null),
fetch(`/api/market/radar/${ticker}`).then((r) => r.ok ? r.json() : null),
]).then(([p, r]) => {
setPiotroski(p);
setRadar(r);
setLoading(false);
}).catch(() => setLoading(false));
}, [ticker]);
async function runAiAnalysis() {
const apiKey = localStorage.getItem("atlas_gemini_key") || "";
if (!apiKey) {
setAiAnalysis("Please set your Gemini API key in Settings first.");
return;
}
setAiLoading(true);
try {
const res = await fetch("/api/analysis/strategy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ticker, question: `Comprehensive research analysis of ${ticker}: competitive position, growth catalysts, and risks`, api_key: apiKey }),
});
if (res.ok) {
const data = await res.json();
setAiAnalysis(typeof data === "string" ? data : data.analysis || JSON.stringify(data));
} else {
setAiAnalysis("API error. Check your Gemini key in Settings.");
}
} catch {
setAiAnalysis("Connection error.");
}
setAiLoading(false);
}
if (loading) return <div className="flex items-center justify-center h-64"><div className="text-accent-green animate-pulse font-mono">Loading...</div></div>;
const fScore = piotroski?.score ?? 0;
const fColor = fScore >= 7 ? "text-accent-green" : fScore >= 4 ? "text-accent-yellow" : "text-accent-red";
const radarMetrics = radar ? [
{ label: "ROE", value: radar.roe, max: 30 },
{ label: "ROA", value: radar.roa, max: 20 },
{ label: "Gross Margin", value: radar.gross_margin, max: 100 },
{ label: "Current Ratio", value: radar.current_ratio, max: 3 },
{ label: "Revenue Growth", value: radar.revenue_growth, max: 50 },
] : [];
return (
<div>
<h1 className="text-2xl font-bold mb-6">
<span className="text-accent-green">{ticker}</span> Research
</h1>
<div className="grid grid-cols-2 gap-4 mb-6">
{/* Piotroski F-Score */}
<div className="bg-bg-card border border-border rounded-lg p-5">
<h3 className="text-text-secondary text-sm font-semibold mb-3">Piotroski F-Score</h3>
<div className={`text-5xl font-mono font-bold ${fColor}`}>{fScore}/9</div>
<div className="text-text-muted text-xs mt-2">
{fScore >= 7 ? "Strong" : fScore >= 4 ? "Moderate" : "Weak"} financial strength
</div>
{piotroski?.details && (
<div className="mt-4 space-y-1.5">
{Object.entries(piotroski.details).map(([key, val]) => (
<div key={key} className="flex justify-between text-sm">
<span className="text-text-muted">{key}</span>
<span className={val.pass ? "text-accent-green" : "text-accent-red"}>
{val.pass ? "✓" : "✗"}
</span>
</div>
))}
</div>
)}
</div>
{/* Financial Radar */}
<div className="bg-bg-card border border-border rounded-lg p-5">
<h3 className="text-text-secondary text-sm font-semibold mb-3">Financial Radar</h3>
{radarMetrics.length > 0 ? (
<div className="space-y-3">
{radarMetrics.map((m) => {
const pct = m.value != null ? Math.min((m.value / m.max) * 100, 100) : 0;
const color = pct > 66 ? "bg-accent-green" : pct > 33 ? "bg-accent-yellow" : "bg-accent-red";
return (
<div key={m.label}>
<div className="flex justify-between text-sm mb-1">
<span className="text-text-muted">{m.label}</span>
<span className="text-text-primary font-mono">{m.value?.toFixed(1) ?? "—"}%</span>
</div>
<div className="h-2 bg-bg-primary rounded-full overflow-hidden">
<div className={`h-full rounded-full ${color} transition-all`} style={{ width: `${pct}%` }} />
</div>
</div>
);
})}
</div>
) : (
<div className="text-text-muted">No data available</div>
)}
</div>
</div>
{/* AI Analysis */}
<div className="bg-bg-card border border-border rounded-lg p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-text-secondary text-sm font-semibold">AI Research Analysis</h3>
<button
onClick={runAiAnalysis}
disabled={aiLoading}
className="bg-accent-green text-bg-primary px-4 py-1.5 rounded-md text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-50"
>
{aiLoading ? "Analyzing..." : "Run Analysis"}
</button>
</div>
{aiAnalysis ? (
<pre className="text-text-primary text-sm whitespace-pre-wrap leading-relaxed font-sans">{aiAnalysis}</pre>
) : (
<div className="text-text-muted text-sm">Click &quot;Run Analysis&quot; to generate AI-powered research report.</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,92 @@
"use client";
import { useState, useEffect } from "react";
const KEYS = [
{ id: "atlas_gemini_key", label: "Gemini API Key", placeholder: "AIza..." },
{ id: "atlas_openai_key", label: "OpenAI API Key", placeholder: "sk-..." },
{ id: "atlas_anthropic_key", label: "Anthropic API Key", placeholder: "sk-ant-..." },
];
export default function SettingsPage() {
const [values, setValues] = useState<Record<string, string>>({});
const [saved, setSaved] = useState(false);
const [backendStatus, setBackendStatus] = useState<"checking" | "ok" | "error">("checking");
useEffect(() => {
// Load from localStorage
const loaded: Record<string, string> = {};
KEYS.forEach((k) => {
loaded[k.id] = localStorage.getItem(k.id) || "";
});
setValues(loaded);
// Check backend health
fetch("/api/health")
.then((r) => r.ok ? setBackendStatus("ok") : setBackendStatus("error"))
.catch(() => setBackendStatus("error"));
}, []);
function handleSave() {
Object.entries(values).forEach(([key, val]) => {
if (val) localStorage.setItem(key, val);
else localStorage.removeItem(key);
});
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
return (
<div className="max-w-2xl">
<h1 className="text-2xl font-bold mb-6">Settings</h1>
{/* 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>
<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"}`} />
<span className="text-text-primary text-sm">
Backend API: {backendStatus === "ok" ? "Connected" : backendStatus === "error" ? "Disconnected" : "Checking..."}
</span>
</div>
</div>
{/* 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>
<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>
<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"
/>
<div className={`w-2.5 h-2.5 rounded-full ${values[k.id] ? "bg-accent-green" : "bg-text-muted"}`} />
</div>
</div>
))}
</div>
<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"
>
{saved ? "Saved!" : "Save Keys"}
</button>
</div>
{/* 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>
<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>
</div>
);
}
@@ -0,0 +1,282 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { useTicker } from "../lib/use-ticker";
interface Indicators {
ticker: string;
current_price: number;
rsi_14: number;
sma: { sma_20: number; sma_50: number; sma_200: number | null };
ema: { ema_12: number; ema_26: number };
macd: { macd: number; signal: number; histogram: number };
bollinger_bands: { upper: number; middle: number; lower: number };
atr_14: number;
}
interface ChartBar {
time: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
}
interface FibLevels {
ticker: string;
high_52w: number;
low_52w: number;
current_price: number;
levels: Record<string, number>;
}
export default function TechnicalPage() {
const { ticker } = useTicker();
const [indicators, setIndicators] = useState<Indicators | null>(null);
const [bars, setBars] = useState<ChartBar[]>([]);
const [fib, setFib] = useState<FibLevels | null>(null);
const [loading, setLoading] = useState(true);
const [period, setPeriod] = useState("6mo");
const chartRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setLoading(true);
Promise.all([
fetch(`/api/technical/${ticker}/indicators`).then((r) => r.ok ? r.json() : null),
fetch(`/api/technical/${ticker}/chart-data?period=${period}`).then((r) => r.ok ? r.json() : null),
fetch(`/api/technical/${ticker}/fibonacci`).then((r) => r.ok ? r.json() : null),
]).then(([ind, chart, fibData]) => {
setIndicators(ind);
setBars(chart?.bars || []);
setFib(fibData);
setLoading(false);
}).catch(() => setLoading(false));
}, [ticker, period]);
// Render chart using lightweight-charts
useEffect(() => {
if (!chartRef.current || bars.length === 0) return;
let chart: any = null;
(async () => {
try {
const { createChart } = await import("lightweight-charts");
chartRef.current!.innerHTML = "";
chart = createChart(chartRef.current!, {
width: chartRef.current!.clientWidth,
height: 400,
layout: { background: { color: "#1A1A26" }, textColor: "#9CA3AF" },
grid: { vertLines: { color: "#2A2A3A" }, horzLines: { color: "#2A2A3A" } },
crosshair: { mode: 0 },
timeScale: { borderColor: "#2A2A3A" },
});
const candlestickSeries = chart.addCandlestickSeries({
upColor: "#00D4AA",
downColor: "#FF4757",
borderUpColor: "#00D4AA",
borderDownColor: "#FF4757",
wickUpColor: "#00D4AA",
wickDownColor: "#FF4757",
});
candlestickSeries.setData(bars);
const volumeSeries = chart.addHistogramSeries({
priceFormat: { type: "volume" },
priceScaleId: "",
});
volumeSeries.priceScale().applyOptions({
scaleMargins: { top: 0.8, bottom: 0 },
});
volumeSeries.setData(
bars.map((b) => ({
time: b.time,
value: b.volume,
color: b.close >= b.open ? "rgba(0,212,170,0.3)" : "rgba(255,71,87,0.3)",
}))
);
chart.timeScale().fitContent();
const handleResize = () => {
if (chartRef.current) chart.applyOptions({ width: chartRef.current.clientWidth });
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
} catch {
// lightweight-charts not available
}
})();
return () => { if (chart) chart.remove(); };
}, [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";
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>
{/* Period Selector */}
<div className="flex gap-2 mb-4">
{["1mo", "3mo", "6mo", "1y", "2y"].map((p) => (
<button
key={p}
onClick={() => setPeriod(p)}
className={`px-3 py-1.5 rounded-md 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"
}`}
>
{p.toUpperCase()}
</button>
))}
</div>
{/* Chart */}
<div className="bg-bg-card border border-border rounded-lg p-4 mb-6">
<div ref={chartRef} className="w-full" style={{ minHeight: 400 }} />
</div>
{/* 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>
</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">
<h3 className="text-text-secondary text-sm font-semibold mb-3">Moving Averages</h3>
<div className="space-y-2">
{[
{ label: "SMA 20", value: indicators.sma.sma_20, signal: indicators.current_price > indicators.sma.sma_20 },
{ label: "SMA 50", value: indicators.sma.sma_50, signal: indicators.current_price > indicators.sma.sma_50 },
{ label: "SMA 200", value: indicators.sma.sma_200, signal: indicators.sma.sma_200 ? indicators.current_price > indicators.sma.sma_200 : null },
{ label: "EMA 12", value: indicators.ema.ema_12, signal: indicators.current_price > indicators.ema.ema_12 },
{ label: "EMA 26", value: indicators.ema.ema_26, signal: indicators.current_price > indicators.ema.ema_26 },
].map((ma) => (
<div key={ma.label} className="flex justify-between items-center text-sm">
<span className="text-text-muted">{ma.label}</span>
<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 ? "ABOVE" : ma.signal === false ? "BELOW" : "N/A"}
</span>
</div>
</div>
))}
</div>
</div>
<div className="bg-bg-card border border-border rounded-lg p-5">
<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>
</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>
</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>
</div>
<div className="flex justify-between text-sm border-t border-border pt-3">
<span className="text-text-muted">BB Width</span>
<span className="text-text-primary font-mono">
{((indicators.bollinger_bands.upper - indicators.bollinger_bands.lower) / indicators.bollinger_bands.middle * 100).toFixed(2)}%
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-text-muted">%B Position</span>
<span className="text-text-primary font-mono">
{((indicators.current_price - indicators.bollinger_bands.lower) / (indicators.bollinger_bands.upper - indicators.bollinger_bands.lower) * 100).toFixed(1)}%
</span>
</div>
</div>
{/* MACD Detail */}
<h3 className="text-text-secondary text-sm font-semibold mb-3 mt-5">MACD (12, 26, 9)</h3>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-text-muted">MACD Line</span>
<span className="text-text-primary font-mono">{indicators.macd.macd.toFixed(4)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-text-muted">Signal Line</span>
<span className="text-text-primary font-mono">{indicators.macd.signal.toFixed(4)}</span>
</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"}`}>
{indicators.macd.histogram.toFixed(4)}
</span>
</div>
</div>
</div>
</div>
)}
{/* Fibonacci Levels */}
{fib && (
<div className="bg-bg-card border border-border rounded-lg p-5">
<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 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"}`}>
${price.toFixed(2)}
</div>
</div>
);
})}
</div>
<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>
)}
</div>
);
}
@@ -0,0 +1,512 @@
"use client";
import { useEffect, useState } from "react";
import { useTicker } from "../lib/use-ticker";
interface DCFInputs {
fcf?: number;
total_debt?: number;
cash?: number;
shares?: number;
}
interface DCFResult {
scenarios?: {
bull?: { intrinsic_value: number; upside: number };
base?: { intrinsic_value: number; upside: number };
bear?: { intrinsic_value: number; upside: number };
};
}
interface Consensus {
target_mean?: number;
target_high?: number;
target_low?: number;
recommendation?: string;
}
interface SensitivityData {
wacc_values: number[];
tg_values: number[];
matrix: (number | null)[][];
}
interface TornadoItem {
variable: string;
low: number;
high: number;
base: number;
}
interface MonteCarloData {
percentile_10: number | null;
median: number | null;
percentile_90: number | null;
mean: number | null;
prob_above_current: number | null;
current_price: number | null;
histogram?: { counts: number[]; bin_edges: number[] };
}
type ValuationTab = "dcf" | "sensitivity" | "montecarlo" | "tornado" | "reverse";
export default function ValuationPage() {
const { ticker } = useTicker();
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
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);
useEffect(() => {
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),
]).then(([i, c, d]) => {
setInputs(i);
setConsensus(c);
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]);
async function runDCF() {
if (!inputs) return;
setDcfLoading(true);
try {
const res = await fetch("/api/valuation/dcf", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ticker,
base_fcf: inputs.fcf,
total_debt: inputs.total_debt,
cash: inputs.cash,
shares: inputs.shares,
wacc: wacc / 100,
terminal_growth: terminalGrowth / 100,
fcf_growth_rate: fcfGrowth / 100,
}),
});
if (res.ok) setDcfResult(await res.json());
} catch { /* */ }
setDcfLoading(false);
}
async function runAdvancedModel(tab: ValuationTab) {
if (!inputs?.fcf || !inputs?.shares) return;
setAdvLoading(true);
const body = {
ticker,
fcf: inputs.fcf,
total_debt: inputs.total_debt || 0,
cash: inputs.cash || 0,
shares: inputs.shares,
wacc: wacc / 100,
terminal_growth: terminalGrowth / 100,
fcf_growth: fcfGrowth / 100,
};
try {
if (tab === "sensitivity") {
const res = await fetch("/api/valuation/sensitivity", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) setSensitivity(await res.json());
} else if (tab === "tornado") {
const res = await fetch("/api/valuation/tornado", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) {
const data = await res.json();
setTornado(data.data || []);
}
} else if (tab === "montecarlo") {
const res = await fetch("/api/valuation/monte-carlo", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...body,
wacc_mean: wacc / 100,
wacc_std: 0.015,
growth_mean: fcfGrowth / 100,
growth_std: 0.03,
term_growth: terminalGrowth / 100,
n_simulations: 5000,
}),
});
if (res.ok) setMonteCarlo(await res.json());
} else if (tab === "reverse") {
const res = await fetch("/api/valuation/reverse-dcf", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) setReverseDCF(await res.json());
}
} catch { /* */ }
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>;
return (
<div>
<h1 className="text-2xl font-bold mb-6">
<span className="text-accent-green">{ticker}</span> Valuation
</h1>
{/* Analyst Consensus */}
{consensus && (
<div className="grid grid-cols-4 gap-3 mb-6">
{[
{ label: "Target Mean", value: consensus.target_mean ? `$${consensus.target_mean.toFixed(2)}` : "—" },
{ label: "Target High", value: consensus.target_high ? `$${consensus.target_high.toFixed(2)}` : "—" },
{ label: "Target Low", value: consensus.target_low ? `$${consensus.target_low.toFixed(2)}` : "—" },
{ label: "Recommendation", value: consensus.recommendation?.toUpperCase() || "—" },
].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 font-mono">{m.value}</div>
</div>
))}
</div>
)}
{/* Tab Navigation */}
<div className="flex gap-1 mb-5 bg-bg-card border border-border rounded-lg p-1">
{[
{ key: "dcf" as ValuationTab, label: "DCF Model" },
{ key: "sensitivity" as ValuationTab, label: "Sensitivity" },
{ key: "montecarlo" as ValuationTab, label: "Monte Carlo" },
{ key: "tornado" as ValuationTab, label: "Tornado" },
{ key: "reverse" as ValuationTab, label: "Reverse DCF" },
].map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex-1 px-3 py-2 rounded-md text-sm font-mono transition-all ${
activeTab === tab.key
? "bg-accent-green text-bg-primary font-semibold"
: "text-text-secondary hover:text-text-primary"
}`}
>
{tab.label}
</button>
))}
</div>
{/* DCF Tab */}
{activeTab === "dcf" && (
<>
<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">DCF Calculator</h3>
{inputs && (
<div className="grid grid-cols-4 gap-3 mb-5 text-sm">
{[
{ label: "Free Cash Flow", value: inputs.fcf ? `$${(inputs.fcf / 1e9).toFixed(2)}B` : "—" },
{ label: "Total Debt", value: inputs.total_debt ? `$${(inputs.total_debt / 1e9).toFixed(2)}B` : "—" },
{ label: "Cash", value: inputs.cash ? `$${(inputs.cash / 1e9).toFixed(2)}B` : "—" },
{ label: "Shares Out", value: inputs.shares ? `${(inputs.shares / 1e9).toFixed(2)}B` : "—" },
].map((m) => (
<div key={m.label} className="bg-bg-primary rounded-md p-3">
<div className="text-text-muted text-xs">{m.label}</div>
<div className="text-text-primary font-mono font-semibold mt-1">{m.value}</div>
</div>
))}
</div>
)}
<div className="grid grid-cols-3 gap-6 mb-5">
<SliderInput label="WACC" value={wacc} onChange={setWacc} min={5} max={20} step={0.5} suffix="%" />
<SliderInput label="Terminal Growth" value={terminalGrowth} onChange={setTerminalGrowth} min={0} max={5} step={0.5} suffix="%" />
<SliderInput label="FCF Growth" value={fcfGrowth} onChange={setFcfGrowth} min={0} max={30} step={1} suffix="%" />
</div>
<button
onClick={runDCF}
disabled={dcfLoading || !inputs}
className="bg-accent-green text-bg-primary px-6 py-2 rounded-md font-semibold hover:opacity-90 transition-opacity disabled:opacity-50"
>
{dcfLoading ? "Calculating..." : "Run DCF"}
</button>
</div>
{dcfResult?.scenarios && (
<div className="grid grid-cols-3 gap-4">
{(["bear", "base", "bull"] as const).map((scenario) => {
const s = dcfResult.scenarios?.[scenario];
if (!s) return null;
const color = scenario === "bull" ? "border-accent-green" : scenario === "bear" ? "border-accent-red" : "border-accent-blue";
const textColor = scenario === "bull" ? "text-accent-green" : scenario === "bear" ? "text-accent-red" : "text-accent-blue";
return (
<div key={scenario} className={`bg-bg-card border-2 ${color} rounded-lg p-5`}>
<h4 className={`${textColor} text-sm font-semibold mb-2 uppercase`}>{scenario} Case</h4>
<div className="text-3xl font-mono font-bold text-text-primary">
${s.intrinsic_value?.toFixed(2)}
</div>
<div className={`text-sm mt-1 font-mono ${s.upside >= 0 ? "text-accent-green" : "text-accent-red"}`}>
{s.upside >= 0 ? "+" : ""}{s.upside?.toFixed(1)}% upside
</div>
</div>
);
})}
</div>
)}
</>
)}
{/* Sensitivity Tab */}
{activeTab === "sensitivity" && (
<div className="bg-bg-card border border-border rounded-lg p-5">
<div className="flex justify-between items-center mb-4">
<h3 className="text-text-secondary text-sm font-semibold">WACC vs Terminal Growth Sensitivity</h3>
<button
onClick={() => runAdvancedModel("sensitivity")}
disabled={advLoading || !inputs?.fcf}
className="bg-accent-green text-bg-primary px-4 py-1.5 rounded-md text-sm font-semibold hover:opacity-90 disabled:opacity-50"
>
{advLoading ? "Computing..." : "Generate"}
</button>
</div>
{sensitivity && (
<div className="overflow-x-auto">
<table className="w-full text-xs font-mono">
<thead>
<tr>
<th className="p-2 text-text-muted text-left">WACC \ TG</th>
{sensitivity.tg_values.map((tg) => (
<th key={tg} className="p-2 text-text-muted text-right">{tg.toFixed(1)}%</th>
))}
</tr>
</thead>
<tbody>
{sensitivity.wacc_values.map((w, ri) => (
<tr key={w} className="border-t border-border/30">
<td className="p-2 text-text-muted font-semibold">{w.toFixed(1)}%</td>
{sensitivity.matrix[ri].map((val, ci) => {
const isCenter = ri === Math.floor(sensitivity.wacc_values.length / 2) && ci === Math.floor(sensitivity.tg_values.length / 2);
return (
<td
key={ci}
className={`p-2 text-right ${
isCenter ? "bg-accent-green/20 text-accent-green font-bold" :
val != null && val > 0 ? "text-text-primary" : "text-text-muted"
}`}
>
{val != null ? `$${val.toFixed(0)}` : "—"}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
{!sensitivity && !advLoading && (
<div className="text-text-muted text-center py-12 text-sm">Click Generate to build the sensitivity matrix</div>
)}
</div>
)}
{/* Monte Carlo Tab */}
{activeTab === "montecarlo" && (
<div className="bg-bg-card border border-border rounded-lg p-5">
<div className="flex justify-between items-center mb-4">
<h3 className="text-text-secondary text-sm font-semibold">Monte Carlo DCF (5,000 simulations)</h3>
<button
onClick={() => runAdvancedModel("montecarlo")}
disabled={advLoading || !inputs?.fcf}
className="bg-accent-green text-bg-primary px-4 py-1.5 rounded-md text-sm font-semibold hover:opacity-90 disabled:opacity-50"
>
{advLoading ? "Simulating..." : "Run Simulation"}
</button>
</div>
{monteCarlo && monteCarlo.median != null && (
<>
<div className="grid grid-cols-5 gap-3 mb-5">
{[
{ label: "10th Pct", value: `$${monteCarlo.percentile_10?.toFixed(2)}`, color: "text-accent-red" },
{ label: "Median", value: `$${monteCarlo.median?.toFixed(2)}`, color: "text-accent-yellow" },
{ label: "Mean", value: `$${monteCarlo.mean?.toFixed(2)}`, color: "text-accent-blue" },
{ label: "90th Pct", value: `$${monteCarlo.percentile_90?.toFixed(2)}`, color: "text-accent-green" },
{ label: "P(> Current)", value: monteCarlo.prob_above_current != null ? `${monteCarlo.prob_above_current}%` : "—", color: "text-text-primary" },
].map((m) => (
<div key={m.label} className="bg-bg-primary rounded-lg p-3 text-center">
<div className="text-text-muted text-xs mb-1">{m.label}</div>
<div className={`font-mono font-bold text-lg ${m.color}`}>{m.value}</div>
</div>
))}
</div>
{/* Histogram */}
{monteCarlo.histogram && (
<div className="mt-4">
<div className="flex items-end gap-px h-40">
{monteCarlo.histogram.counts.map((count, i) => {
const maxCount = Math.max(...monteCarlo.histogram!.counts);
const height = maxCount > 0 ? (count / maxCount) * 100 : 0;
const binMid = (monteCarlo.histogram!.bin_edges[i] + monteCarlo.histogram!.bin_edges[i + 1]) / 2;
const isAboveCurrent = monteCarlo.current_price != null && binMid > monteCarlo.current_price;
return (
<div
key={i}
className={`flex-1 rounded-t-sm ${isAboveCurrent ? "bg-accent-green" : "bg-accent-red"}`}
style={{ height: `${Math.max(height, 1)}%` }}
title={`$${monteCarlo.histogram!.bin_edges[i].toFixed(0)}-$${monteCarlo.histogram!.bin_edges[i + 1].toFixed(0)}: ${count}`}
/>
);
})}
</div>
<div className="flex justify-between text-text-muted text-xs font-mono mt-1">
<span>${monteCarlo.histogram.bin_edges[0].toFixed(0)}</span>
{monteCarlo.current_price && <span className="text-accent-yellow">Current: ${monteCarlo.current_price.toFixed(0)}</span>}
<span>${monteCarlo.histogram.bin_edges[monteCarlo.histogram.bin_edges.length - 1].toFixed(0)}</span>
</div>
</div>
)}
</>
)}
{!monteCarlo && !advLoading && (
<div className="text-text-muted text-center py-12 text-sm">Click Run Simulation to perform Monte Carlo analysis</div>
)}
</div>
)}
{/* Tornado Tab */}
{activeTab === "tornado" && (
<div className="bg-bg-card border border-border rounded-lg p-5">
<div className="flex justify-between items-center mb-4">
<h3 className="text-text-secondary text-sm font-semibold">Tornado Chart Variable Impact (±10%)</h3>
<button
onClick={() => runAdvancedModel("tornado")}
disabled={advLoading || !inputs?.fcf}
className="bg-accent-green text-bg-primary px-4 py-1.5 rounded-md text-sm font-semibold hover:opacity-90 disabled:opacity-50"
>
{advLoading ? "Computing..." : "Generate"}
</button>
</div>
{tornado.length > 0 && (
<div className="space-y-3">
{tornado.map((item) => {
const range = item.high - item.low;
const maxRange = Math.max(...tornado.map((t) => t.high - t.low));
const widthPct = maxRange > 0 ? (range / maxRange) * 100 : 0;
const baseOffset = maxRange > 0 ? ((item.base - item.low) / maxRange) * 100 : 50;
return (
<div key={item.variable} className="flex items-center gap-3">
<div className="w-28 text-right text-text-muted text-sm shrink-0">{item.variable}</div>
<div className="flex-1 relative h-8 bg-bg-primary rounded overflow-hidden">
<div
className="absolute h-full bg-gradient-to-r from-accent-red via-accent-yellow to-accent-green rounded opacity-80"
style={{ width: `${widthPct}%`, left: 0 }}
/>
<div
className="absolute top-0 h-full w-0.5 bg-text-primary z-10"
style={{ left: `${baseOffset}%` }}
/>
</div>
<div className="w-32 shrink-0 flex justify-between text-xs font-mono">
<span className="text-accent-red">${item.low.toFixed(0)}</span>
<span className="text-accent-green">${item.high.toFixed(0)}</span>
</div>
</div>
);
})}
<div className="text-text-muted text-xs mt-2 font-mono text-center">
Base value: ${tornado[0]?.base.toFixed(2)} | Sensitivity ±10% of each variable
</div>
</div>
)}
{tornado.length === 0 && !advLoading && (
<div className="text-text-muted text-center py-12 text-sm">Click Generate to build the tornado chart</div>
)}
</div>
)}
{/* Reverse DCF Tab */}
{activeTab === "reverse" && (
<div className="bg-bg-card border border-border rounded-lg p-5">
<div className="flex justify-between items-center mb-4">
<h3 className="text-text-secondary text-sm font-semibold">Reverse DCF Implied Growth Rate</h3>
<button
onClick={() => runAdvancedModel("reverse")}
disabled={advLoading || !inputs?.fcf}
className="bg-accent-green text-bg-primary px-4 py-1.5 rounded-md text-sm font-semibold hover:opacity-90 disabled:opacity-50"
>
{advLoading ? "Computing..." : "Calculate"}
</button>
</div>
{reverseDCF && (
<div className="text-center py-8">
<div className="text-text-muted text-sm mb-2">The market is pricing in an annual FCF growth rate of:</div>
<div className={`text-5xl font-mono font-bold ${
reverseDCF.implied_growth != null && reverseDCF.implied_growth >= 0 ? "text-accent-green" : "text-accent-red"
}`}>
{reverseDCF.implied_growth != null ? `${reverseDCF.implied_growth.toFixed(2)}%` : "N/A"}
</div>
<div className="text-text-muted text-sm mt-3 font-mono">
Current Price: ${reverseDCF.current_price?.toFixed(2)} | WACC: {wacc}% | Terminal Growth: {terminalGrowth}%
</div>
{reverseDCF.implied_growth != null && (
<div className="mt-4 text-sm">
<span className="text-text-muted">Your assumption: </span>
<span className="text-accent-blue font-mono font-bold">{fcfGrowth}%</span>
<span className="text-text-muted"> vs Market implied: </span>
<span className={`font-mono font-bold ${reverseDCF.implied_growth >= fcfGrowth ? "text-accent-green" : "text-accent-red"}`}>
{reverseDCF.implied_growth.toFixed(2)}%
</span>
<span className="text-text-muted"> </span>
<span className={`font-semibold ${reverseDCF.implied_growth > fcfGrowth ? "text-accent-red" : "text-accent-green"}`}>
{reverseDCF.implied_growth > fcfGrowth ? "Market expects MORE growth (potentially overvalued)" : "Market expects LESS growth (potentially undervalued)"}
</span>
</div>
)}
</div>
)}
{!reverseDCF && !advLoading && (
<div className="text-text-muted text-center py-12 text-sm">Click Calculate to find the implied growth rate</div>
)}
</div>
)}
</div>
);
}
function SliderInput({ label, value, onChange, min, max, step, suffix }: {
label: string; value: number; onChange: (v: number) => void;
min: number; max: number; step: number; suffix: string;
}) {
return (
<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>
</div>
<input
type="range"
min={min} max={max} step={step}
value={value}
onChange={(e) => onChange(parseFloat(e.target.value))}
className="w-full accent-[#00D4AA]"
/>
</div>
);
}