mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-23 15:48:05 +00:00
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:
co-authored by
Claude Opus 4.6
parent
56a9561f71
commit
b2acda81ee
@@ -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.
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>
|
||||
);
|
||||
}
|
||||
@@ -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 "Run Analysis" 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user