Refresh Morgan Terminal UI and README media

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

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+53 -33
View File
@@ -8,6 +8,7 @@ import os
import re
from typing import Any, Dict, List
import httpx
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
@@ -32,39 +33,58 @@ class SimpleQuestionRequest(BaseModel):
api_key: str = ""
def _call_gemini(
async def _call_gemini(
api_key: str,
prompt: str,
max_tokens: int = 4096,
temperature: float = 0.7,
) -> str:
"""Call Gemini API directly and return text response."""
import urllib.request
import urllib.error
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={api_key}"
payload = json.dumps({
"""Call Gemini API directly (non-blocking) and return text response."""
url = (
"https://generativelanguage.googleapis.com/v1beta/models/"
f"gemini-2.0-flash:generateContent?key={api_key}"
)
payload = {
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {"maxOutputTokens": max_tokens, "temperature": temperature},
}).encode("utf-8")
"generationConfig": {
"maxOutputTokens": max_tokens,
"temperature": temperature,
},
}
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=60) as resp:
data = json.loads(resp.read().decode("utf-8"))
candidates = data.get("candidates", [])
if candidates:
parts = candidates[0].get("content", {}).get("parts", [])
if parts:
return parts[0].get("text", "")
return "No response from Gemini."
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
logger.error("Gemini API error %d: %s", e.code, body)
raise HTTPException(status_code=e.code, detail=f"Gemini API error: {body[:200]}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Gemini call failed: {e}")
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.post(
url,
json=payload,
headers={"Content-Type": "application/json"},
)
except httpx.TimeoutException as exc:
logger.warning("Gemini API timeout: %s", exc)
raise HTTPException(status_code=504, detail="Gemini API timeout") from exc
except httpx.HTTPError as exc:
logger.exception("Gemini API request error")
raise HTTPException(status_code=502, detail=f"Gemini request failed: {exc}") from exc
if resp.status_code >= 400:
body = resp.text[:200]
logger.error("Gemini API error %d: %s", resp.status_code, body)
raise HTTPException(
status_code=resp.status_code, detail=f"Gemini API error: {body}"
)
try:
data = resp.json()
except ValueError as exc:
logger.exception("Gemini response JSON decode failed")
raise HTTPException(status_code=502, detail="Gemini response not JSON") from exc
candidates = data.get("candidates", [])
if candidates:
parts = candidates[0].get("content", {}).get("parts", [])
if parts:
return parts[0].get("text", "")
return "No response from Gemini."
def _build_anomaly_filing_context(
@@ -197,7 +217,7 @@ Provide a detailed, professional analysis in markdown format. Include:
Be specific with numbers and data. Answer in the same language as the question."""
result = _call_gemini(api_key, prompt)
result = await _call_gemini(api_key, prompt)
return {"ticker": req.ticker.upper(), "analysis": result}
@@ -223,7 +243,7 @@ Provide a detailed risk assessment including:
Be specific and use the financial data provided. Answer in markdown format."""
result = _call_gemini(api_key, prompt)
result = await _call_gemini(api_key, prompt)
return {"ticker": req.ticker.upper(), "analysis": result}
@@ -249,7 +269,7 @@ Provide insights on:
Use markdown format with headers and bullet points."""
result = _call_gemini(api_key, prompt)
result = await _call_gemini(api_key, prompt)
return {"ticker": req.ticker.upper(), "report": result}
@@ -276,7 +296,7 @@ Check for:
Use markdown format. Be thorough but fair."""
result = _call_gemini(api_key, prompt)
result = await _call_gemini(api_key, prompt)
return {"ticker": req.ticker.upper(), "forensic": result}
@@ -288,7 +308,7 @@ async def extract_financials(req: AnalysisRequest):
raise HTTPException(status_code=400, detail="API key required.")
context = _get_financial_context(req.ticker.upper())
result = _call_gemini(api_key, f"Summarize the key financial data for analysis:\n\n{context}")
result = await _call_gemini(api_key, f"Summarize the key financial data for analysis:\n\n{context}")
return {"ticker": req.ticker.upper(), "financials": result}
@@ -360,7 +380,7 @@ Output rules:
raw = ""
try:
raw = _call_gemini(api_key, prompt, max_tokens=2048, temperature=0.2)
raw = await _call_gemini(api_key, prompt, max_tokens=2048, temperature=0.2)
parsed = _parse_llm_json_object(raw)
return _anomaly_response_from_parsed(parsed)
except json.JSONDecodeError:
@@ -420,7 +440,7 @@ async def translate_text(req: TranslateRequest):
)
try:
result = _call_gemini(api_key, prompt, max_tokens=8192, temperature=0.2)
result = await _call_gemini(api_key, prompt, max_tokens=8192, temperature=0.2)
return {"translated_text": result}
except Exception as exc:
logger.exception("Translation failed")
@@ -485,7 +505,7 @@ async def institutional_analysis(req: InstitutionalRequest):
prompt += "\n\nIMPORTANT: Write the entire analysis in Japanese (日本語). Keep financial terms in English."
try:
raw = _call_gemini(api_key, prompt, max_tokens=8192, temperature=0.3)
raw = await _call_gemini(api_key, prompt, max_tokens=8192, temperature=0.3)
except HTTPException:
raise
except Exception as exc:
+40 -27
View File
@@ -1,12 +1,16 @@
"""Crypto router -- live cryptocurrency prices from Bithumb (KRW) and Binance (USD)."""
import asyncio
import logging
from typing import List
import httpx
from fastapi import APIRouter, HTTPException
from server.models.schemas import CryptoPrice
router = APIRouter()
logger = logging.getLogger(__name__)
# Top 20 symbols tracked by default
TOP_SYMBOLS = [
@@ -22,19 +26,17 @@ _BITHUMB_MAP = {
}
def _fetch_binance_prices(symbols: List[str]) -> dict:
async def _fetch_binance_prices(client: httpx.AsyncClient, symbols: List[str]) -> dict:
"""Fetch USD prices from Binance API for the given symbols."""
import requests
url = "https://api.binance.com/api/v3/ticker/price"
try:
resp = requests.get(url, timeout=10)
resp = await client.get(url, timeout=10)
resp.raise_for_status()
data = resp.json()
except Exception:
except (httpx.HTTPError, ValueError) as exc:
logger.warning("Binance prices fetch failed: %s", exc)
return {}
# Build lookup: symbol (no USDT suffix) -> price
prices = {}
lookup = {item["symbol"]: float(item["price"]) for item in data}
for sym in symbols:
@@ -44,37 +46,40 @@ def _fetch_binance_prices(symbols: List[str]) -> dict:
return prices
def _fetch_bithumb_prices(symbols: List[str]) -> dict:
"""Fetch KRW prices from Bithumb public API."""
import requests
prices = {}
for sym in symbols:
async def _fetch_bithumb_prices(client: httpx.AsyncClient, symbols: List[str]) -> dict:
"""Fetch KRW prices from Bithumb public API (parallel)."""
async def _one(sym: str):
bithumb_sym = _BITHUMB_MAP.get(sym.upper(), sym.upper())
url = f"https://api.bithumb.com/public/ticker/{bithumb_sym}_KRW"
try:
resp = requests.get(url, timeout=5)
resp = await client.get(url, timeout=5)
resp.raise_for_status()
data = resp.json()
if data.get("status") == "0000":
closing = data.get("data", {}).get("closing_price")
if closing:
prices[sym.upper()] = float(closing)
except Exception:
continue
return sym.upper(), float(closing)
except (httpx.HTTPError, ValueError) as exc:
logger.debug("Bithumb %s failed: %s", sym, exc)
return None
results = await asyncio.gather(*[_one(s) for s in symbols], return_exceptions=True)
prices = {}
for r in results:
if isinstance(r, tuple):
prices[r[0]] = r[1]
return prices
def _fetch_binance_24h_changes(symbols: List[str]) -> dict:
async def _fetch_binance_24h_changes(client: httpx.AsyncClient, symbols: List[str]) -> dict:
"""Fetch 24h percentage changes from Binance."""
import requests
url = "https://api.binance.com/api/v3/ticker/24hr"
try:
resp = requests.get(url, timeout=10)
resp = await client.get(url, timeout=10)
resp.raise_for_status()
data = resp.json()
except Exception:
except (httpx.HTTPError, ValueError) as exc:
logger.warning("Binance 24h changes fetch failed: %s", exc)
return {}
changes = {}
@@ -97,9 +102,12 @@ async def crypto_prices():
USD prices come from Binance; KRW prices from Bithumb.
"""
try:
usd_prices = _fetch_binance_prices(TOP_SYMBOLS)
krw_prices = _fetch_bithumb_prices(TOP_SYMBOLS)
changes = _fetch_binance_24h_changes(TOP_SYMBOLS)
async with httpx.AsyncClient() as client:
usd_prices, krw_prices, changes = await asyncio.gather(
_fetch_binance_prices(client, TOP_SYMBOLS),
_fetch_bithumb_prices(client, TOP_SYMBOLS),
_fetch_binance_24h_changes(client, TOP_SYMBOLS),
)
results: List[CryptoPrice] = []
for sym in TOP_SYMBOLS:
@@ -112,6 +120,7 @@ async def crypto_prices():
))
return results
except Exception as exc:
logger.exception("Crypto prices endpoint failed")
raise HTTPException(status_code=500, detail=f"Crypto prices failed: {exc}") from exc
@@ -124,9 +133,12 @@ async def crypto_price(symbol: str):
"""Return current price for a single cryptocurrency symbol."""
try:
sym = symbol.upper()
usd_prices = _fetch_binance_prices([sym])
krw_prices = _fetch_bithumb_prices([sym])
changes = _fetch_binance_24h_changes([sym])
async with httpx.AsyncClient() as client:
usd_prices, krw_prices, changes = await asyncio.gather(
_fetch_binance_prices(client, [sym]),
_fetch_bithumb_prices(client, [sym]),
_fetch_binance_24h_changes(client, [sym]),
)
return CryptoPrice(
symbol=sym,
@@ -136,4 +148,5 @@ async def crypto_price(symbol: str):
change_24h_pct=changes.get(sym),
)
except Exception as exc:
logger.exception("Crypto price endpoint failed")
raise HTTPException(status_code=500, detail=f"Crypto price failed: {exc}") from exc
+9 -2
View File
@@ -1,5 +1,8 @@
"""EDINET Japan — optional annual report (有価証券報告書) + link fallbacks."""
import asyncio
import logging
from fastapi import APIRouter, HTTPException, Query
from server.models.schemas import EdgarSectionsResponse
@@ -10,11 +13,12 @@ from server.services.edinet_filing_service import (
)
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/links/{ticker}", summary="EDINET portal links (no API key required)")
async def edinet_links(ticker: str):
return get_edinet_links(ticker)
return await asyncio.to_thread(get_edinet_links, ticker)
@router.get(
@@ -30,12 +34,15 @@ async def edinet_sections(
),
):
try:
sections, status, html_frag, meta = get_edinet_sections(ticker)
sections, status, html_frag, meta = await asyncio.to_thread(
get_edinet_sections, ticker
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc:
logger.exception("EDINET sections failed for %s", ticker)
raise HTTPException(status_code=500, detail=f"EDINET failed: {exc}") from exc
links = meta.get("links") if isinstance(meta.get("links"), dict) else None
@@ -2,9 +2,11 @@
from typing import Any, Dict, List, Optional
import logging
from fastapi import APIRouter
router = APIRouter()
logger = logging.getLogger(__name__)
def _safe_df_to_dict(df: Any) -> List[Dict[str, Any]]:
@@ -19,6 +21,7 @@ def _safe_df_to_dict(df: Any) -> List[Dict[str, Any]]:
return []
return df.fillna(0).reset_index().to_dict(orient="records")
except Exception:
logger.exception("estimates endpoint failed")
return []
@@ -84,6 +87,7 @@ async def full_estimates(ticker: str) -> Dict[str, Any]:
"price_targets": price_targets,
}
except Exception:
logger.exception("estimates endpoint failed")
return {
"ticker": ticker.upper(),
"earnings_estimate": [],
@@ -120,6 +124,7 @@ async def earnings_dates(ticker: str) -> Dict[str, Any]:
"earnings_history": history_records,
}
except Exception:
logger.exception("estimates endpoint failed")
return {
"ticker": ticker.upper(),
"earnings_dates": [],
@@ -152,6 +157,7 @@ async def growth_estimates(ticker: str) -> Dict[str, Any]:
"eps_trend": eps_records,
}
except Exception:
logger.exception("estimates endpoint failed")
return {
"ticker": ticker.upper(),
"growth_estimates": [],
@@ -1,10 +1,12 @@
"""Financial statements router -- statements, highlights, and ratios."""
import logging
from typing import Any, Dict, List, Optional
from fastapi import APIRouter
router = APIRouter()
logger = logging.getLogger(__name__)
def _df_to_periods(df: Any, max_periods: int = 5) -> List[Dict[str, Any]]:
@@ -130,6 +132,7 @@ async def financial_statements(ticker: str) -> Dict[str, Any]:
"revenue_yoy_growth": revenue_growth,
}
except Exception:
logger.exception("financials endpoint failed")
return {
"ticker": ticker.upper(),
"income_statement": [],
@@ -173,6 +176,7 @@ async def financial_highlights(ticker: str) -> Dict[str, Any]:
"book_value": _safe_get(info, "bookValue"),
"earnings_growth": _safe_get(info, "earningsGrowth"),
"revenue_growth": _safe_get(info, "revenueGrowth"),
"currency": info.get("currency") or info.get("financialCurrency") or "USD",
}
# Derive EBITDA margin if both values exist
@@ -183,6 +187,7 @@ async def financial_highlights(ticker: str) -> Dict[str, Any]:
return highlights
except Exception:
logger.exception("financials endpoint failed")
return {
"ticker": ticker.upper(),
"company_name": "",
@@ -207,6 +212,7 @@ async def kpi_history(ticker: str) -> Dict[str, Any]:
return build_kpi_history(ticker)
except Exception:
logger.exception("financials endpoint failed")
return {
"ticker": ticker.upper(),
"quarters": [],
@@ -270,6 +276,7 @@ async def financial_ratios(ticker: str) -> Dict[str, Any]:
return ratios
except Exception:
logger.exception("financials endpoint failed")
return {
"ticker": ticker.upper(),
"trailing_pe": None, "forward_pe": None,
+64 -42
View File
@@ -2,6 +2,7 @@
import asyncio
import os
from functools import partial
from typing import Any, Dict, Optional
from fastapi import APIRouter, Query
@@ -13,60 +14,74 @@ from server.services.smart_money_service import get_smart_money_snapshot
from server.services.yield_fx_service import get_yield_fx_pair
router = APIRouter()
_MACRO_TIMEOUT_SECONDS = 8.0
async def _run_macro_task(
func: Any,
fallback: Dict[str, Any],
*args: Any,
timeout: float = _MACRO_TIMEOUT_SECONDS,
) -> Dict[str, Any]:
try:
call = partial(func, *args)
return await asyncio.wait_for(asyncio.to_thread(call), timeout=timeout)
except asyncio.TimeoutError:
return {**fallback, "error": "upstream_timeout"}
except Exception as exc:
return {**fallback, "error": str(exc)}
@router.get("/quadrant", summary="Global growth vs inflation quadrant (Z-scores)")
async def macro_quadrant() -> Dict[str, Any]:
try:
return await asyncio.to_thread(get_global_macro_quadrant)
except Exception as exc:
return {"updated_at": None, "points": [], "error": str(exc)}
return await _run_macro_task(
get_global_macro_quadrant,
{"updated_at": None, "points": []},
)
@router.get("/yield-fx", summary="US10Y spread vs FX (usdjpy|eurusd|usdkrw)")
async def macro_yield_fx(
pair: str = Query("usdjpy", description="usdjpy, eurusd, or usdkrw"),
) -> Dict[str, Any]:
try:
return await asyncio.to_thread(get_yield_fx_pair, pair)
except Exception as exc:
return {
return await _run_macro_task(
get_yield_fx_pair,
{
"pair": pair,
"updated_at": None,
"series": [],
"error": str(exc),
}
},
pair,
)
@router.get("/smart-money", summary="Copper/Gold + RORO composite")
async def macro_smart_money() -> Dict[str, Any]:
try:
return await asyncio.to_thread(get_smart_money_snapshot)
except Exception as exc:
return {
return await _run_macro_task(
get_smart_money_snapshot,
{
"updated_at": None,
"roro_z": None,
"roro_label": None,
"copper_gold": [],
"components": {},
"error": str(exc),
}
},
)
@router.get("/subfactors", summary="4-category macro subfactor breakdown + cycle stage")
async def macro_subfactors() -> Dict[str, Any]:
from server.services.macro_cycle import get_subfactor_breakdown
try:
return await asyncio.to_thread(get_subfactor_breakdown)
except Exception as exc:
return {
return await _run_macro_task(
get_subfactor_breakdown,
{
"updated_at": None,
"composite_score": 0.0,
"cycle_stage": "Unknown",
"categories": {},
"error": str(exc),
}
},
)
@router.get("/fred/{series_id}", summary="FRED time series (public CSV)")
@@ -75,12 +90,19 @@ async def macro_fred(
start: Optional[str] = Query(None, description="YYYY-MM-DD"),
end: Optional[str] = Query(None, description="YYYY-MM-DD"),
) -> Dict[str, Any]:
rows = await asyncio.to_thread(
macro_fetcher.fetch_fred_series,
series_id,
start,
end,
)
try:
rows = await asyncio.wait_for(
asyncio.to_thread(
macro_fetcher.fetch_fred_series,
series_id,
start,
end,
),
timeout=_MACRO_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
rows = []
return {"series": series_id.strip().upper(), "count": 0, "data": rows, "error": "upstream_timeout"}
return {"series": series_id.strip().upper(), "count": len(rows), "data": rows}
@@ -94,28 +116,27 @@ async def macro_oecd_mei() -> Dict[str, Any]:
async def macro_oecd_cli() -> Dict[str, Any]:
from server.services.oecd_cycle import get_oecd_cli_snapshot
try:
return await asyncio.to_thread(get_oecd_cli_snapshot)
except Exception as exc:
return {"updated_at": None, "countries": [], "error": str(exc)}
return await _run_macro_task(
get_oecd_cli_snapshot,
{"updated_at": None, "countries": []},
)
@router.get("/snapshot", summary="Macro cycle heatmap, countries, asset valuation")
async def macro_snapshot() -> Dict[str, Any]:
from server.services.macro_cycle import get_macro_cycle_snapshot
try:
return await asyncio.to_thread(get_macro_cycle_snapshot)
except Exception as exc:
return {
return await _run_macro_task(
get_macro_cycle_snapshot,
{
"updated_at": None,
"cycle_score": 0.0,
"regime": "Unknown",
"cycle_heatmap": [],
"country_heatmap": [],
"asset_valuation": [],
"error": str(exc),
}
},
)
@router.get("/korea", summary="Korea indicators (ECOS or yfinance fallback)")
@@ -134,10 +155,11 @@ async def macro_economic_calendar(
) -> Dict[str, Any]:
from server.services.economic_calendar import get_economic_calendar
try:
return await asyncio.to_thread(get_economic_calendar, days)
except Exception as exc:
return {"events": [], "next_high_impact": None, "total": 0, "error": str(exc)}
return await _run_macro_task(
get_economic_calendar,
{"events": [], "next_high_impact": None, "total": 0},
days,
)
@router.get("/ecos", summary="Korea Bank ECOS (requires ECOS_API_KEY)")
+24 -2
View File
@@ -1,11 +1,13 @@
"""Market Data router -- sector info, financial trends, comps, health metrics."""
import logging
from typing import Any, Dict, List
from fastapi import APIRouter, Query
from server.utils.ticker_utils import AssetType, detect_asset_type
router = APIRouter()
logger = logging.getLogger(__name__)
def _safe_float(val, default=0.0):
@@ -45,10 +47,12 @@ async def market_indices():
"change": f"{pct:+.2f}%",
"positive": pct >= 0,
})
except Exception:
except Exception as exc:
logger.warning("indices: fetch %s failed: %s", s["symbol"], exc)
results.append({"label": s["label"], "symbol": s["symbol"], "price": "", "change": "", "positive": True})
return results
except Exception:
logger.exception("indices: outer failure")
return []
@@ -59,6 +63,7 @@ async def market_overview():
return await get_market_overview()
except Exception as e:
logger.exception("market_overview failed")
return {"error": str(e), "data": None}
@@ -69,6 +74,7 @@ async def sector_heatmap():
return await get_sector_heatmap()
except Exception as e:
logger.exception("sector_heatmap failed")
return {"error": str(e), "data": None}
@@ -93,6 +99,7 @@ async def market_overview_by_ticker(ticker: str):
return {"asset_type": AssetType.EQUITY.value, "data": await get_equity_overview(ticker)}
except Exception as e:
logger.exception("overview/%s failed", ticker)
return {"error": str(e), "asset_type": AssetType.EQUITY.value, "data": None}
@@ -103,6 +110,7 @@ async def etf_holdings(ticker: str):
return {"ticker": ticker.upper(), "holdings": await get_etf_holdings(ticker)}
except Exception as e:
logger.exception("etf/%s/holdings failed", ticker)
return {"error": str(e), "ticker": ticker.upper(), "holdings": []}
@@ -114,6 +122,7 @@ async def commodity_seasonal(ticker: str):
data = await get_commodity_overview(ticker)
return {"ticker": ticker.upper(), "seasonal_pattern": data.get("seasonal_pattern", {})}
except Exception as e:
logger.exception("commodity/%s/seasonal failed", ticker)
return {"error": str(e), "ticker": ticker.upper(), "seasonal_pattern": {}}
@@ -124,6 +133,7 @@ async def commodity_correlations(ticker: str):
return {"ticker": ticker.upper(), "correlations": await compute_commodity_correlations(ticker)}
except Exception as e:
logger.exception("commodity/%s/correlations failed", ticker)
return {"error": str(e), "ticker": ticker.upper(), "correlations": {}}
@@ -164,8 +174,11 @@ async def sector_industry(ticker: str):
"website": info.get("website"),
"ipo_date": info.get("ipoExpectedDate") or info.get("firstTradeDateEpochUtc"),
"description": info.get("longBusinessSummary"),
"currency": info.get("currency") or info.get("financialCurrency") or "USD",
"exchange": info.get("exchange"),
}
except Exception:
logger.exception("sector/%s failed", ticker)
return {"sector": "N/A", "industry": "N/A"}
@@ -201,6 +214,7 @@ async def financial_trend(ticker: str):
return {"years": years, "revenue": revenue, "net_income": net_income, "operating_margin": op_margin, "fcf": fcf_list}
except Exception:
logger.exception("trend/%s failed", ticker)
return {"years": [], "revenue": [], "net_income": [], "operating_margin": [], "fcf": []}
@@ -212,6 +226,7 @@ async def peer_valuation_multiples(ticker: str):
return build_peer_comparison(ticker)
except Exception:
logger.exception("peers/%s failed", ticker)
return {
"ticker": ticker.upper(),
"sector": "",
@@ -243,6 +258,7 @@ async def industry_comps(tickers: str = Query(..., description="Comma-separated
})
return {"tickers": ticker_list, "data": results}
except Exception:
logger.exception("comps failed for %s", tickers)
return {"tickers": [], "data": []}
@@ -376,7 +392,8 @@ async def financial_health(ticker: str):
"debt_to_equity": round(debt_to_equity, 2) if debt_to_equity is not None else None,
"red_flags": red_flags,
}
except Exception as e:
except Exception:
logger.exception("health/%s failed", ticker)
return fallback
@@ -455,6 +472,7 @@ async def piotroski_score(ticker: str):
return {"total": score, "details": details, "score": score}
except Exception:
logger.exception("piotroski/%s failed", ticker)
return {"total": 0, "details": {}, "score": 0}
@@ -477,6 +495,7 @@ async def quick_quote(ticker: str):
"change_pct": round(ch, 2) if ch is not None else None,
}
except Exception:
logger.exception("quote/%s failed", ticker)
return {"ticker": ticker.upper(), "current_price": None, "change_pct": None}
@@ -512,9 +531,11 @@ async def sankey_data(ticker: str):
nivo = sankey_nivo_for_ticker(ticker)
except Exception:
logger.warning("sankey/%s nivo fallback used", ticker)
nivo = {"nodes": [], "links": []}
return {"nodes": nodes, "nivo": nivo}
except Exception:
logger.exception("sankey/%s failed", ticker)
return {"nodes": [], "nivo": {"nodes": [], "links": []}}
@@ -533,4 +554,5 @@ async def radar_metrics(ticker: str):
"revenue_growth": _safe_float(info.get("revenueGrowth", 0)) * 100,
}
except Exception:
logger.exception("radar/%s failed", ticker)
return {}
+14 -3
View File
@@ -1,5 +1,7 @@
"""News router -- Finviz scrape + Google News RSS + Yahoo Finance RSS."""
import asyncio
import logging
from typing import List
from fastapi import APIRouter, HTTPException, Query
@@ -8,6 +10,7 @@ from server.models.schemas import NewsItem
from server.services.news_aggregator import merge_news_for_router
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get(
@@ -18,9 +21,12 @@ router = APIRouter()
async def get_news(ticker: str):
"""Return recent articles: Finviz table, Google News RSS, Yahoo headline RSS."""
try:
merged = merge_news_for_router(ticker.upper(), max_articles=40)
merged = await asyncio.to_thread(
merge_news_for_router, ticker.upper(), 40
)
return [NewsItem(**item) for item in merged]
except Exception as exc:
logger.exception("News fetch failed for %s", ticker)
raise HTTPException(status_code=500, detail=f"News fetch failed: {exc}") from exc
@@ -34,7 +40,9 @@ async def ai_news_summary(
):
"""Fetch merged news and optionally summarize with Gemini."""
try:
all_items = merge_news_for_router(ticker.upper(), max_articles=30)
all_items = await asyncio.to_thread(
merge_news_for_router, ticker.upper(), 30
)
headlines: List[str] = []
for item in all_items:
title = (item.get("title") or "").strip()
@@ -61,7 +69,9 @@ Provide a concise 3-5 sentence executive summary of the overall sentiment and ke
Headlines:
{headline_text}"""
response = _generate_with_retry(model, prompt, {"temperature": 0.2, "max_output_tokens": 512})
response = await asyncio.to_thread(
_generate_with_retry, model, prompt, {"temperature": 0.2, "max_output_tokens": 512}
)
summary = (response.text or "").strip() if response else ""
return {
@@ -71,4 +81,5 @@ Headlines:
"items": all_items,
}
except Exception as exc:
logger.exception("AI news summary failed for %s", ticker)
raise HTTPException(status_code=500, detail=f"AI news summary failed: {exc}") from exc
@@ -6,6 +6,7 @@ import uuid
from pathlib import Path
from typing import List
import logging
from fastapi import APIRouter, HTTPException, UploadFile, File, Header
from pydantic import BaseModel, Field
@@ -16,6 +17,7 @@ from server.models.schemas import (
)
router = APIRouter()
logger = logging.getLogger(__name__)
# Simple file-based persistence (production would use Supabase / Postgres)
_PORTFOLIO_FILE = Path(__file__).resolve().parent.parent.parent / "data" / "portfolio.json"
@@ -41,6 +43,7 @@ def _load_positions() -> List[dict]:
with open(_PORTFOLIO_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
logger.exception("portfolio endpoint failed")
return []
+4 -1
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import asyncio
from fastapi import APIRouter
from server.services.research_dashboard import build_research_dashboard
@@ -12,4 +14,5 @@ router = APIRouter()
@router.get("/dashboard/{ticker}", summary="F-Score history, DuPont tree, Sankey, waterfall, anomalies")
async def research_dashboard(ticker: str):
"""Quant-only payload for Research page widgets."""
return build_research_dashboard(ticker).model_dump()
dashboard = await asyncio.to_thread(build_research_dashboard, ticker)
return dashboard.model_dump()
@@ -2,9 +2,11 @@
from __future__ import annotations
import logging
from fastapi import APIRouter
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/search")
@@ -15,6 +17,7 @@ async def search_stocks(filters: dict):
return await run_screener(filters)
except Exception as e:
logger.exception("screener endpoint failed")
return {"error": str(e), "data": []}
@@ -34,6 +37,7 @@ async def backtest(body: dict):
rebalance_months=body.get("rebalance_months"),
)
except Exception as e:
logger.exception("screener endpoint failed")
return {"error": str(e)}
@@ -59,4 +63,5 @@ async def portfolio_backtest(body: dict):
benchmark_ticker=str(body.get("benchmark_ticker") or "SPY"),
)
except Exception as e:
logger.exception("screener endpoint failed")
return {"error": str(e)}
@@ -1,11 +1,13 @@
"""Valuation router -- DCF calculation, smart defaults, analyst consensus,
sensitivity analysis, Monte Carlo simulation, reverse DCF, and tornado charts."""
import logging
from fastapi import APIRouter
from pydantic import BaseModel
from typing import Optional, Dict, Any, List
router = APIRouter()
logger = logging.getLogger(__name__)
def _safe_float(val, default=0.0):
@@ -55,6 +57,7 @@ async def dcf_inputs(ticker: str):
return {"fcf": fcf, "total_debt": total_debt, "cash": cash, "shares": shares}
except Exception:
logger.exception("dcf-inputs fallback")
return {"fcf": None, "total_debt": 0, "cash": 0, "shares": None}
@@ -118,6 +121,7 @@ async def calculate_dcf(inputs: DCFInputsBody):
},
}
except Exception:
logger.exception("calculate_dcf failed")
return {"base": None, "bull": None, "bear": None, "current_price": None}
@@ -147,6 +151,7 @@ async def smart_defaults(ticker: str):
"sector": sector, "industry": industry,
}
except Exception:
logger.exception("smart_defaults/%s failed", ticker)
return {"wacc": 9, "terminal_growth": 2.5, "fcf_growth": 10, "sector": "N/A", "industry": "N/A"}
@@ -195,6 +200,7 @@ async def sensitivity_analysis(body: SensitivityBody):
)
return result
except Exception as exc:
logger.exception("valuation endpoint failed")
return {"error": str(exc)}
@@ -209,6 +215,7 @@ async def tornado_chart(body: SensitivityBody):
)
return {"data": result}
except Exception as exc:
logger.exception("valuation endpoint failed")
return {"error": str(exc)}
@@ -245,6 +252,7 @@ async def monte_carlo_dcf(body: MonteCarloBody):
result["values"] = []
return result
except Exception as exc:
logger.exception("valuation endpoint failed")
return {"error": str(exc)}
@@ -276,6 +284,7 @@ async def reverse_dcf_endpoint(body: ReverseDCFBody):
"current_price": current_price,
}
except Exception as exc:
logger.exception("valuation endpoint failed")
return {"error": str(exc)}
@@ -295,4 +304,5 @@ async def analyst_consensus(ticker: str):
"num_analysts": info.get("numberOfAnalystOpinions", 0),
}
except Exception:
logger.exception("consensus/%s failed", ticker)
return {"target_mean": None, "target_high": None, "target_low": None, "target_median": None, "recommendation": "N/A", "num_analysts": 0}
+37 -2
View File
@@ -147,6 +147,24 @@ async def get_equity_overview(ticker: str) -> dict:
t = yf.Ticker(ticker.upper())
info = t.info or {}
# Fallback: derive 52W low/high from history when yfinance returns 0 or None
# (common for some Asian tickers like 005930.KS where info.fiftyTwoWeekLow == 0)
hi52 = _safe_num(info.get("fiftyTwoWeekHigh"))
lo52 = _safe_num(info.get("fiftyTwoWeekLow"))
if not hi52 or not lo52 or lo52 == 0:
try:
hist = t.history(period="1y")
if hist is not None and len(hist) > 0:
h_max = float(hist["High"].max())
l_min = float(hist["Low"].min())
if not hi52:
hi52 = h_max
if not lo52 or lo52 == 0:
lo52 = l_min
except Exception:
pass
return {
"name": info.get("longName") or info.get("shortName", ticker.upper()),
"sector": info.get("sector"),
@@ -155,8 +173,25 @@ async def get_equity_overview(ticker: str) -> dict:
"pe_ratio": _safe_num(info.get("trailingPE")) or _safe_num(info.get("forwardPE")),
"dividend_yield": _safe_num(info.get("dividendYield")),
"beta": _safe_num(info.get("beta")),
"high_52w": _safe_num(info.get("fiftyTwoWeekHigh")),
"low_52w": _safe_num(info.get("fiftyTwoWeekLow")),
"high_52w": hi52,
"low_52w": lo52,
"price": _safe_num(info.get("currentPrice") or info.get("regularMarketPrice")),
"description": info.get("longBusinessSummary"),
"currency": info.get("currency") or info.get("financialCurrency") or "USD",
"exchange": info.get("exchange"),
"country": info.get("country"),
"full_time_employees": info.get("fullTimeEmployees"),
"average_volume": _safe_num(info.get("averageVolume")),
"trailing_pe": _safe_num(info.get("trailingPE")),
"forward_pe": _safe_num(info.get("forwardPE")),
"enterprise_to_ebitda": _safe_num(info.get("enterpriseToEbitda")),
"debt_to_equity": _safe_num(info.get("debtToEquity")),
"return_on_equity": _safe_num(info.get("returnOnEquity")),
"return_on_assets": _safe_num(info.get("returnOnAssets")),
"free_cashflow": _safe_num(info.get("freeCashflow")),
"revenue_growth": _safe_num(info.get("revenueGrowth")),
"profit_margins": _safe_num(info.get("profitMargins")),
"target_mean_price": _safe_num(info.get("targetMeanPrice")),
"recommendation": info.get("recommendationKey"),
"num_analysts": info.get("numberOfAnalystOpinions"),
}
@@ -204,7 +204,7 @@ def _fetch_fred_series(fred_code: str):
return web.DataReader(fred_code, "fred", start=start).dropna()
except Exception:
url = f"https://fred.stlouisfed.org/graph/fredgraph.csv?id={fred_code}"
response = requests.get(url, timeout=20)
response = requests.get(url, timeout=8)
response.raise_for_status()
df = pd.read_csv(StringIO(response.text))
date_column = "DATE" if "DATE" in df.columns else "observation_date"
@@ -222,7 +222,7 @@ def _fetch_worldbank_latest(country_code: str, indicator: str) -> Optional[float
f"https://api.worldbank.org/v2/country/{country_code}"
f"/indicator/{indicator}?format=json&per_page=5&mrv=3"
)
resp = requests.get(url, timeout=12)
resp = requests.get(url, timeout=6)
data = resp.json()
if len(data) > 1 and data[1]:
for item in data[1]:
@@ -44,7 +44,7 @@ def fetch_fred_series(
url = f"{_FRED_CSV}?id={sid}"
try:
req = Request(url, headers={"User-Agent": _USER_AGENT})
with urlopen(req, timeout=45) as resp:
with urlopen(req, timeout=10) as resp:
text = resp.read().decode("utf-8", errors="replace")
except Exception:
return []
@@ -104,7 +104,7 @@ def fetch_ecos_series(
url = "https://ecos.bok.or.kr" + path
try:
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
with urllib.request.urlopen(req, timeout=30) as resp:
with urllib.request.urlopen(req, timeout=10) as resp:
raw = resp.read().decode("utf-8", errors="replace")
except Exception:
return []
@@ -5,14 +5,14 @@ All quantitative; no LLM. See claude.md hybrid separation principle.
from __future__ import annotations
import threading
import time
from typing import Any, Dict, List, Optional, Tuple
import pandas as pd
from server.utils.safe_float import _safe_float
from server.services.market_fetcher import _get_annual_financials_balance_cashflow, _get_row_series
from server.services.financial_metrics import get_dupont_altman_redflags_yoy
from server.services.financial_metrics_ext import get_income_statement_sankey_data
from server.models.schemas import (
DuPontTreeNode,
DuPontTreePayload,
@@ -31,6 +31,11 @@ try:
except ImportError:
yf = None # type: ignore[assignment]
_DASHBOARD_CACHE_TTL_SECONDS = 300
_dashboard_cache_lock = threading.RLock()
_dashboard_cache: Dict[str, tuple[float, ResearchDashboardResponse]] = {}
_dashboard_inflight: Dict[str, threading.Event] = {}
_FSCORE_KEYS = [
("profitable", "Net income > 0"),
("ocf_pos", "Operating cash flow > 0"),
@@ -197,15 +202,80 @@ def _dupont_tree_from_df(dupont_df: pd.DataFrame) -> Optional[DuPontTreePayload]
)
def _build_sankey_nivo(ticker: str) -> SankeyGraphPayload:
d = get_income_statement_sankey_data(ticker)
rev = max(d.get("revenue") or 0, 1)
cogs = min(abs(d.get("cogs") or 0), rev * 0.999)
gp = max(d.get("gross_profit") or 0, 0)
opex = max(d.get("opex") or 0, 0)
oi = d.get("operating_income") or 0
tax = max(d.get("tax_interest_other") or 0, 0)
ni = d.get("net_income") or 0
def _build_dupont_tree_from_statements(fin: pd.DataFrame, bal: pd.DataFrame) -> Optional[DuPontTreePayload]:
if fin is None or fin.empty or bal is None or bal.empty:
return None
rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue")
ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders")
total_assets = _get_row_series(bal, "Total Assets")
total_equity = _get_row_series(
bal,
"Total Stockholder Equity",
"Stockholders Equity",
"Total Equity Gross Minority Interest",
)
if rev is None or ni is None or total_assets is None or total_equity is None:
return None
rows: List[Dict[str, Any]] = []
for i, d in enumerate(fin.columns[:6]):
revenue = _safe_float(rev.get(d))
net_income = _safe_float(ni.get(d))
assets = _safe_float(total_assets.get(d))
equity = _safe_float(total_equity.get(d))
if not revenue or not assets or not equity:
continue
npm = (net_income / revenue * 100) if net_income is not None else None
at = revenue / assets if assets else None
em = assets / equity if equity else None
roe = (net_income / equity * 100) if (net_income is not None and equity) else None
if npm is None or at is None or em is None or roe is None:
continue
yr = int(str(d)[:4]) if str(d)[:4].isdigit() else (d.year if hasattr(d, "year") else (2024 - i))
rows.append(
{
"Year": yr,
"Revenue": revenue,
"Net Income": net_income,
"NPM %": round(npm, 2),
"Asset Turnover": round(at, 4),
"Equity Mult.": round(em, 2),
"ROE %": round(roe, 2),
}
)
if not rows:
return None
return _dupont_tree_from_df(pd.DataFrame(rows))
def _build_sankey_nivo_from_fin(fin: pd.DataFrame) -> SankeyGraphPayload:
if fin is None or fin.empty:
return SankeyGraphPayload(nodes=[], links=[])
rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue")
cogs = _get_row_series(fin, "Cost Of Revenue", "Cost Of Goods Sold")
gross = _get_row_series(fin, "Gross Profit")
op_inc = _get_row_series(fin, "Operating Income", "EBIT")
ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders")
if rev is None or len(rev) == 0:
return SankeyGraphPayload(nodes=[], links=[])
d = rev.index[0]
revenue = abs(_safe_float(rev.get(d)) or 0)
cogs_val = abs(_safe_float(cogs.get(d)) or 0) if cogs is not None and d in cogs.index else 0
gross_val = _safe_float(gross.get(d)) if gross is not None and d in gross.index else None
if gross_val is None:
gross_val = (revenue - cogs_val) if revenue else 0
gross_val = abs(gross_val or 0)
operating_income = _safe_float(op_inc.get(d)) if op_inc is not None and d in op_inc.index else 0
net_income = _safe_float(ni.get(d)) if ni is not None and d in ni.index else 0
opex = max(0, gross_val - operating_income) if gross_val >= operating_income else 0
tax_interest_other = max(0, operating_income - net_income) if (operating_income - net_income) > 0 else abs(min(0, operating_income - net_income))
nodes = [
SankeyNivoNode(id="revenue", label="Revenue"),
@@ -216,20 +286,27 @@ def _build_sankey_nivo(ticker: str) -> SankeyGraphPayload:
SankeyNivoNode(id="tax_other", label="Tax & other"),
SankeyNivoNode(id="net_income", label="Net income"),
]
revenue_safe = max(revenue, 1)
gross_safe = max(gross_val, revenue_safe - min(cogs_val, revenue_safe * 0.999))
links: List[SankeyNivoLink] = [
SankeyNivoLink(source="revenue", target="cogs", value=float(cogs)),
SankeyNivoLink(source="revenue", target="gross_profit", value=float(max(gp, rev - cogs))),
SankeyNivoLink(source="revenue", target="cogs", value=float(min(cogs_val, revenue_safe * 0.999))),
SankeyNivoLink(source="revenue", target="gross_profit", value=float(max(gross_safe, revenue_safe - cogs_val))),
]
gp_v = links[-1].value
links.append(SankeyNivoLink(source="gross_profit", target="opex", value=float(min(opex, gp_v))))
links.append(SankeyNivoLink(source="gross_profit", target="operating_income", value=float(max(oi, gp_v - opex))))
links.append(SankeyNivoLink(source="gross_profit", target="operating_income", value=float(max(operating_income, gp_v - opex))))
oi_v = links[-1].value
tax_v = min(tax, max(oi_v, 0))
tax_v = min(tax_interest_other, max(oi_v, 0))
links.append(SankeyNivoLink(source="operating_income", target="tax_other", value=float(tax_v)))
links.append(SankeyNivoLink(source="operating_income", target="net_income", value=float(max(abs(ni), 0))))
links.append(SankeyNivoLink(source="operating_income", target="net_income", value=float(max(abs(net_income), 0))))
return SankeyGraphPayload(nodes=nodes, links=links)
def _build_sankey_nivo(ticker: str) -> SankeyGraphPayload:
fin, _, _ = _get_annual_financials_balance_cashflow(ticker)
return _build_sankey_nivo_from_fin(fin)
def _build_waterfall(fin: pd.DataFrame) -> List[WaterfallStep]:
if fin is None or fin.empty or len(fin.columns) < 2:
return []
@@ -352,8 +429,27 @@ def sankey_nivo_for_ticker(ticker: str) -> Dict[str, Any]:
return _build_sankey_nivo(ticker).model_dump()
def build_research_dashboard(ticker: str) -> ResearchDashboardResponse:
sym = ticker.upper().strip()
def _get_cached_dashboard(sym: str) -> Optional[ResearchDashboardResponse]:
now = time.time()
with _dashboard_cache_lock:
cached = _dashboard_cache.get(sym)
if not cached:
return None
ts, payload = cached
if now - ts >= _DASHBOARD_CACHE_TTL_SECONDS:
_dashboard_cache.pop(sym, None)
return None
return payload.model_copy(deep=True)
def _store_cached_dashboard(sym: str, payload: ResearchDashboardResponse) -> None:
if payload.error:
return
with _dashboard_cache_lock:
_dashboard_cache[sym] = (time.time(), payload.model_copy(deep=True))
def _build_research_dashboard_uncached(sym: str) -> ResearchDashboardResponse:
fin, bal, cf = _get_annual_financials_balance_cashflow(sym)
if fin is None or fin.empty or bal is None or bal.empty:
return ResearchDashboardResponse(ticker=sym, error="Insufficient financial statements")
@@ -362,11 +458,8 @@ def build_research_dashboard(ticker: str) -> ResearchDashboardResponse:
cf = pd.DataFrame()
total, fseries = _build_fscore_series(sym, fin, bal, cf)
dq = get_dupont_altman_redflags_yoy(sym)
dupont_df = dq.get("dupont") if dq else None
tree = _dupont_tree_from_df(dupont_df) if isinstance(dupont_df, pd.DataFrame) else None
sankey = _build_sankey_nivo(sym)
tree = _build_dupont_tree_from_statements(fin, bal)
sankey = _build_sankey_nivo_from_fin(fin)
waterfall = _build_waterfall(fin)
anomalies = _detect_anomalies(fin, bal)
@@ -379,3 +472,40 @@ def build_research_dashboard(ticker: str) -> ResearchDashboardResponse:
waterfall=waterfall,
anomalies=anomalies,
)
def build_research_dashboard(ticker: str) -> ResearchDashboardResponse:
sym = ticker.upper().strip()
cached = _get_cached_dashboard(sym)
if cached is not None:
return cached
wait_event: Optional[threading.Event] = None
with _dashboard_cache_lock:
cached = _get_cached_dashboard(sym)
if cached is not None:
return cached
wait_event = _dashboard_inflight.get(sym)
if wait_event is None:
wait_event = threading.Event()
_dashboard_inflight[sym] = wait_event
leader = True
else:
leader = False
if not leader:
wait_event.wait(timeout=15)
cached = _get_cached_dashboard(sym)
if cached is not None:
return cached
return _build_research_dashboard_uncached(sym)
try:
payload = _build_research_dashboard_uncached(sym)
_store_cached_dashboard(sym, payload)
return payload.model_copy(deep=True)
finally:
with _dashboard_cache_lock:
event = _dashboard_inflight.pop(sym, None)
if event is not None:
event.set()