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. Institutional-style equity research terminal built with Next.js 14 and FastAPI.
>
> See the [main README](../README.md) for full documentation. 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 ## Quick Start
```bash ### Backend
# Backend (from atlas-terminal/)
pip install -r requirements.txt
PYTHONPATH="." uvicorn server.main:app --port 8000
# 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 install
npm run dev npm run dev
``` ```
- **Frontend:** http://localhost:3000 ### Local URLs
- **Backend:** http://localhost:8000
- **API Docs:** http://localhost:8000/docs
## 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 ## Recent Work
- **Backend:** FastAPI, Python 3.12+, yfinance, yahooquery, Google Gemini
- **Database:** SQLite (local) / PostgreSQL (production)
## 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). 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.
- **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.
**Earlier** It is currently optimized as a desktop-first personal research environment rather than a SaaS product.
- 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.
+1
View File
@@ -17,6 +17,7 @@
"@nivo/core": "^0.99.0", "@nivo/core": "^0.99.0",
"@nivo/sankey": "^0.99.0", "@nivo/sankey": "^0.99.0",
"lightweight-charts": "^5.1.0", "lightweight-charts": "^5.1.0",
"lucide-react": "^1.8.0",
"next": "14.2.35", "next": "14.2.35",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
@@ -16,7 +16,7 @@ const SidebarClient = dynamic(
ssr: false, ssr: false,
loading: () => ( loading: () => (
<aside <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 aria-hidden
/> />
), ),
@@ -27,9 +27,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
return ( return (
<> <>
<TickerBar /> <TickerBar />
<div className="flex min-h-screen pt-[52px]"> <div className="flex min-h-screen bg-surface-canvas pt-[56px]">
<SidebarClient /> <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} {children}
</main> </main>
<ChatPanel /> <ChatPanel />
@@ -1,23 +1,14 @@
"use client"; "use client";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { Bot, SendHorizontal } from "lucide-react";
import { useTicker } from "../lib/use-ticker";
export function ChatPanel() { export function ChatPanel() {
const [messages, setMessages] = useState<Array<{ role: string; content: string }>>([]); const [messages, setMessages] = useState<Array<{ role: string; content: string }>>([]);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [ticker, setTicker] = useState("AAPL");
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const { ticker } = useTicker();
useEffect(() => {
const saved = localStorage.getItem("atlas_active_ticker");
if (saved) setTicker(saved);
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail) setTicker(detail);
};
window.addEventListener("atlas-ticker-change", handler);
return () => window.removeEventListener("atlas-ticker-change", handler);
}, []);
useEffect(() => { useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
@@ -60,25 +51,32 @@ export function ChatPanel() {
]; ];
return ( return (
<aside className="w-[380px] bg-bg-secondary border-l border-border fixed top-[52px] bottom-0 right-0 flex flex-col z-40"> <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="p-4 border-b border-border font-bold text-lg text-text-primary"> <div className="border-b border-border bg-surface-sunken px-4 py-4">
🤖 AI Copilot <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>
<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 ? ( {messages.length === 0 ? (
<div className="text-text-secondary text-sm"> <div className="text-sm text-text-secondary">
<p> <p>
Ask me anything about{" "} Ask me anything about <span className="font-semibold text-brand-navy">{ticker}</span>.
<span className="text-accent-green font-semibold">{ticker}</span>.
</p> </p>
<p className="mt-3 font-semibold text-text-primary">Try:</p> <p className="mt-4 text-xs font-semibold uppercase tracking-[0.12em] text-text-muted">Suggested prompts</p>
<ul className="flex flex-col gap-1.5 mt-2"> <ul className="mt-2 flex flex-col gap-2">
{suggestions.map((q) => ( {suggestions.map((q) => (
<li <li
key={q} key={q}
onClick={() => setInput(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} {q}
</li> </li>
@@ -89,32 +87,33 @@ export function ChatPanel() {
messages.map((m, i) => ( messages.map((m, i) => (
<div <div
key={i} 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" m.role === "user"
? "bg-bg-card text-text-primary self-end" ? "self-end bg-brand-navy text-white"
: "bg-accent-green/10 text-text-primary self-start" : "self-start border border-border bg-surface-raised text-text-primary"
}`} }`}
> >
{m.content} {m.content}
</div> </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>
<div className="p-3 border-t border-border"> <div className="border-t border-border bg-surface-raised p-3">
<div className="flex gap-2 bg-bg-card rounded-lg border border-border px-3.5 py-2.5"> <div className="flex gap-2 rounded-md border border-border bg-surface-sunken px-3.5 py-2.5">
<input <input
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()} onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="Ask anything..." 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 <button
onClick={handleSend} 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 Send
</button> </button>
</div> </div>
@@ -12,6 +12,7 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { chartPalette } from "../../lib/chart-theme";
export interface QuadrantPoint { export interface QuadrantPoint {
id: string; id: string;
@@ -32,10 +33,10 @@ const FLAG: Record<string, string> = {
}; };
const QUADRANT_COLOR: Record<string, string> = { const QUADRANT_COLOR: Record<string, string> = {
Reflation: "#FFD93D", Reflation: chartPalette.gold,
Recovery: "#00D4AA", Recovery: chartPalette.green,
Stagflation: "#FF4757", Stagflation: chartPalette.red,
Overheat: "#4DA6FF", Overheat: chartPalette.blue,
}; };
function QuadrantTooltip({ function QuadrantTooltip({
@@ -54,8 +55,8 @@ function QuadrantTooltip({
{flag} {p.label} {flag} {p.label}
</div> </div>
<div className="text-text-muted">Quadrant: {p.quadrant}</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-brand-navy">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-blue">Inflation Z: {p.inflation_z?.toFixed(2)}</div>
</div> </div>
); );
} }
@@ -73,31 +74,31 @@ export function GlobalMacroQuadrantChart({ points }: { points: QuadrantPoint[] }
<div className="h-[340px] w-full"> <div className="h-[340px] w-full">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<ScatterChart margin={{ top: 16, right: 16, bottom: 8, left: 8 }}> <ScatterChart margin={{ top: 16, right: 16, bottom: 8, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#2A2A3A" /> <CartesianGrid strokeDasharray="3 3" stroke={chartPalette.grid} />
<XAxis <XAxis
type="number" type="number"
dataKey="growth_z" dataKey="growth_z"
name="Growth Z" name="Growth Z"
stroke="#6B7280" stroke={chartPalette.textMuted}
tick={{ fill: "#9CA3AF", fontSize: 11 }} tick={{ fill: chartPalette.textMuted, fontSize: 11 }}
label={{ value: "Growth momentum (Z)", position: "bottom", fill: "#6B7280", fontSize: 11 }} label={{ value: "Growth momentum (Z)", position: "bottom", fill: chartPalette.textMuted, fontSize: 11 }}
/> />
<YAxis <YAxis
type="number" type="number"
dataKey="inflation_z" dataKey="inflation_z"
name="Inflation Z" name="Inflation Z"
stroke="#6B7280" stroke={chartPalette.textMuted}
tick={{ fill: "#9CA3AF", fontSize: 11 }} tick={{ fill: chartPalette.textMuted, fontSize: 11 }}
label={{ value: "Inflation momentum (Z)", angle: -90, position: "insideLeft", fill: "#6B7280", 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 x={0} stroke={chartPalette.neutral} strokeDasharray="4 4" />
<ReferenceLine y={0} stroke="#4B5563" strokeDasharray="4 4" /> <ReferenceLine y={0} stroke={chartPalette.neutral} strokeDasharray="4 4" />
<Tooltip content={<QuadrantTooltip />} cursor={{ strokeDasharray: "3 3" }} /> <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) => ( {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> </Scatter>
</ScatterChart> </ScatterChart>
</ResponsiveContainer> </ResponsiveContainer>
@@ -10,6 +10,7 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { chartPalette } from "../../lib/chart-theme";
export interface CopperGoldRow { export interface CopperGoldRow {
date: string; date: string;
@@ -34,7 +35,7 @@ function RoroGauge({ z, label }: { z: number | null; label: string | null }) {
<path <path
d="M 30 100 A 70 70 0 0 1 170 100" d="M 30 100 A 70 70 0 0 1 170 100"
fill="none" fill="none"
stroke="#2A2A3A" stroke={chartPalette.grid}
strokeWidth="10" strokeWidth="10"
strokeLinecap="round" strokeLinecap="round"
/> />
@@ -48,21 +49,21 @@ function RoroGauge({ z, label }: { z: number | null; label: string | null }) {
/> />
<defs> <defs>
<linearGradient id="roroGrad" x1="0%" y1="0%" x2="100%" y2="0%"> <linearGradient id="roroGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#FF4757" /> <stop offset="0%" stopColor={chartPalette.red} />
<stop offset="50%" stopColor="#FFD93D" /> <stop offset="50%" stopColor={chartPalette.gold} />
<stop offset="100%" stopColor="#00D4AA" /> <stop offset="100%" stopColor={chartPalette.green} />
</linearGradient> </linearGradient>
</defs> </defs>
<line x1={cx} y1={cy} x2={x2} y2={y2} stroke="#F3F4F6" strokeWidth="3" strokeLinecap="round" /> <line x1={cx} y1={cy} x2={x2} y2={y2} stroke={chartPalette.text} strokeWidth="3" strokeLinecap="round" />
<circle cx={cx} cy={cy} r="6" fill="#00D4AA" /> <circle cx={cx} cy={cy} r="6" fill={chartPalette.navy} />
<text x="30" y="108" fill="#6B7280" fontSize="9" fontFamily="monospace"> <text x="30" y="108" fill={chartPalette.textMuted} fontSize="9" fontFamily="monospace">
Fear Fear
</text> </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 Greed
</text> </text>
</svg> </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> <p className="text-text-muted text-xs font-mono">Z = {z != null ? z.toFixed(2) : "—"}</p>
</div> </div>
); );
@@ -79,7 +80,7 @@ export function SmartMoneyPanel({
}) { }) {
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch"> <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} /> <RoroGauge z={roroZ} label={roroLabel} />
</div> </div>
<div className="min-h-[260px]"> <div className="min-h-[260px]">
@@ -91,19 +92,19 @@ export function SmartMoneyPanel({
) : ( ) : (
<ResponsiveContainer width="100%" height={260}> <ResponsiveContainer width="100%" height={260}>
<LineChart data={copperGold} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}> <LineChart data={copperGold} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#2A2A3A" /> <CartesianGrid strokeDasharray="3 3" stroke={chartPalette.grid} />
<XAxis dataKey="date" tick={{ fill: "#9CA3AF", fontSize: 9 }} minTickGap={32} /> <XAxis dataKey="date" tick={{ fill: chartPalette.textMuted, fontSize: 9 }} minTickGap={32} />
<YAxis tick={{ fill: "#9CA3AF", fontSize: 10 }} domain={["auto", "auto"]} /> <YAxis tick={{ fill: chartPalette.textMuted, fontSize: 10 }} domain={["auto", "auto"]} />
<Tooltip <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 }} /> <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 <Line
type="monotone" type="monotone"
dataKey="ratio_ma20" dataKey="ratio_ma20"
name="MA20" name="MA20"
stroke="#4DA6FF" stroke={chartPalette.blue}
dot={false} dot={false}
strokeWidth={1.5} strokeWidth={1.5}
/> />
@@ -10,6 +10,7 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { chartPalette } from "../../lib/chart-theme";
export interface YieldFxRow { export interface YieldFxRow {
date: string; date: string;
@@ -45,24 +46,24 @@ export function YieldFxDualAxisChart({
<p className="text-text-muted text-xs font-mono mb-2">{meta.title}</p> <p className="text-text-muted text-xs font-mono mb-2">{meta.title}</p>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<ComposedChart data={rows} margin={{ top: 8, right: 12, left: 0, bottom: 0 }}> <ComposedChart data={rows} margin={{ top: 8, right: 12, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#2A2A3A" /> <CartesianGrid strokeDasharray="3 3" stroke={chartPalette.grid} />
<XAxis dataKey="date" tick={{ fill: "#9CA3AF", fontSize: 10 }} minTickGap={24} /> <XAxis dataKey="date" tick={{ fill: chartPalette.textMuted, fontSize: 10 }} minTickGap={24} />
<YAxis <YAxis
yAxisId="left" yAxisId="left"
tick={{ fill: "#00D4AA", fontSize: 10 }} tick={{ fill: chartPalette.navy, fontSize: 10 }}
domain={["auto", "auto"]} 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 <YAxis
yAxisId="right" yAxisId="right"
orientation="right" orientation="right"
tick={{ fill: "#4DA6FF", fontSize: 10 }} tick={{ fill: chartPalette.blue, fontSize: 10 }}
domain={["auto", "auto"]} 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 <Tooltip
contentStyle={{ background: "#1A1A26", border: "1px solid #2A2A3A", fontSize: 12 }} contentStyle={{ background: chartPalette.canvas, border: `1px solid ${chartPalette.grid}`, fontSize: 12 }}
labelStyle={{ color: "#F3F4F6" }} labelStyle={{ color: chartPalette.text }}
/> />
<Legend wrapperStyle={{ fontSize: 11 }} /> <Legend wrapperStyle={{ fontSize: 11 }} />
<Line <Line
@@ -70,7 +71,7 @@ export function YieldFxDualAxisChart({
type="monotone" type="monotone"
dataKey="spread_pct" dataKey="spread_pct"
name="US10Y peer (ppt)" name="US10Y peer (ppt)"
stroke="#00D4AA" stroke={chartPalette.navy}
dot={false} dot={false}
strokeWidth={2} strokeWidth={2}
/> />
@@ -79,7 +80,7 @@ export function YieldFxDualAxisChart({
type="monotone" type="monotone"
dataKey="fx" dataKey="fx"
name={meta.fx} name={meta.fx}
stroke="#4DA6FF" stroke={chartPalette.blue}
dot={false} dot={false}
strokeWidth={2} strokeWidth={2}
/> />
@@ -3,6 +3,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { KpiSection, type KpiHistoryData } from "./KpiSection"; import { KpiSection, type KpiHistoryData } from "./KpiSection";
import { PeerComparison, type PeerComparisonData } from "./PeerComparison"; import { PeerComparison, type PeerComparisonData } from "./PeerComparison";
import { Card } from "../ui/Card";
import { SectionHeading } from "../ui/SectionHeading";
import { StatCard } from "../ui/StatCard";
interface EquityOverviewProps { interface EquityOverviewProps {
ticker: string; ticker: string;
@@ -43,31 +46,25 @@ export function EquityOverview({ ticker, sector, health }: EquityOverviewProps)
]; ];
return ( return (
<div> <div className="atlas-page">
<h1 className="text-2xl font-bold mb-1"> <SectionHeading level={1}>{ticker} Overview</SectionHeading>
<span className="text-accent-green">{ticker}</span> Overview
</h1>
{sector?.current_price != null && ( {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"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
{metrics.map((m) => ( {metrics.map((m) => (
<div key={m.label} className="bg-bg-card border border-border rounded-lg p-4"> <StatCard key={m.label} label={m.label} value={m.value} />
<div className="text-text-muted text-xs mb-1">{m.label}</div>
<div className="text-text-primary font-semibold">{m.value}</div>
</div>
))} ))}
</div> </div>
<ConsensusGauge sector={sector} /> <ConsensusGauge sector={sector} />
<KpiSection data={kpiData} /> <KpiSection data={kpiData} />
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-4"> <div className="grid grid-cols-1 gap-4 mb-4 lg:grid-cols-4">
<Card title="Altman Z-Score" value={health?.altman_z != null ? Number(health.altman_z).toFixed(2) : "—"} /> <OverviewStat 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) : "—"} /> <OverviewStat 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` : "—"} /> <OverviewStat 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) : "—"} /> <OverviewStat title="D/E Ratio" value={health?.debt_to_equity != null ? Number(health.debt_to_equity).toFixed(2) : "—"} />
</div> </div>
<div className="bg-bg-card border border-border rounded-lg p-5"> <Card title="DuPont Analysis">
<h3 className="text-text-secondary text-sm font-semibold mb-3">DuPont Analysis</h3>
{!!health?.dupont && typeof health.dupont === "object" && health.dupont !== null ? ( {!!health?.dupont && typeof health.dupont === "object" && health.dupont !== null ? (
<div className="space-y-2"> <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 className="text-text-muted">No data</div>
)} )}
</div> </Card>
<PeerComparison currentTicker={ticker} data={peerData} /> <PeerComparison currentTicker={ticker} data={peerData} />
</div> </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; const targetPct = range > 0 ? Math.max(0, Math.min(100, ((target - gaugeLow) / range) * 100)) : 50;
return ( 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"> <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>} {count != null && <span className="text-text-muted text-xs">{count} analysts</span>}
</div> </div>
<div className="flex items-baseline gap-4 mb-4"> <div className="flex items-baseline gap-4 mb-4">
@@ -135,25 +131,24 @@ function ConsensusGauge({ sector }: { sector: Record<string, unknown> | null })
</div> </div>
</div> </div>
{/* Visual gauge bar */} {/* 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 */} {/* 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 */} {/* 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>
<div className="flex justify-between text-text-muted text-xs font-mono"> <div className="flex justify-between text-text-muted text-xs font-mono">
<span>${gaugeLow.toFixed(0)}</span> <span>${gaugeLow.toFixed(0)}</span>
<span>${gaugeHigh.toFixed(0)}</span> <span>${gaugeHigh.toFixed(0)}</span>
</div> </div>
</div> </Card>
); );
} }
function Card({ title, value }: { title: string; value: string }) { function OverviewStat({ title, value }: { title: string; value: string }) {
return ( return (
<div className="bg-bg-card border border-border rounded-lg p-5"> <Card title={title}>
<h3 className="text-text-secondary text-sm font-semibold mb-3">{title}</h3>
<div className="text-3xl font-mono font-bold text-text-primary">{value}</div> <div className="text-3xl font-mono font-bold text-text-primary">{value}</div>
</div> </Card>
); );
} }
@@ -1,5 +1,7 @@
"use client"; "use client";
import { chartPalette } from "../../lib/chart-theme";
export interface KpiHistoryData { export interface KpiHistoryData {
ticker: string; ticker: string;
quarters: string[]; quarters: string[];
@@ -63,8 +65,8 @@ function Sparkline({ values }: { values: Array<number | null> }) {
return ( return (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-16"> <svg viewBox={`0 0 ${width} ${height}`} className="w-full h-16">
<path d={path} fill="none" stroke="#00D4AA" strokeWidth="2" strokeLinecap="round" /> <path d={path} fill="none" stroke={chartPalette.blue} strokeWidth="2" strokeLinecap="round" />
<circle cx={lastX} cy={lastY} r="3" fill="#00D4AA" /> <circle cx={lastX} cy={lastY} r="3" fill={chartPalette.gold} />
</svg> </svg>
); );
} }
@@ -1,5 +1,6 @@
"use client"; "use client";
import { chartPalette } from "../../lib/chart-theme";
import type { FScoreCriterionSeries } from "./types"; import type { FScoreCriterionSeries } from "./types";
function BinarySparkline({ history }: { history: { year: number; pass_flag: boolean }[] }) { function BinarySparkline({ history }: { history: { year: number; pass_flag: boolean }[] }) {
@@ -19,14 +20,14 @@ function BinarySparkline({ history }: { history: { year: number; pass_flag: bool
return ( return (
<svg viewBox={`0 0 ${w} ${h}`} className="w-20 h-5 shrink-0" aria-hidden> <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) => ( {pts.map((p) => (
<circle <circle
key={p.pt.year} key={p.pt.year}
cx={p.x} cx={p.x}
cy={p.y} cy={p.y}
r={3} r={3}
fill={p.pt.pass_flag ? "#00D4AA" : "#F87171"} fill={p.pt.pass_flag ? chartPalette.green : chartPalette.red}
/> />
))} ))}
</svg> </svg>
@@ -1,20 +1,21 @@
"use client"; "use client";
import { ResponsiveSankey } from "@nivo/sankey"; import { ResponsiveSankey } from "@nivo/sankey";
import { chartPalette } from "../../lib/chart-theme";
import type { SankeyNivoLink, SankeyNivoNode } from "./types"; import type { SankeyNivoLink, SankeyNivoNode } from "./types";
const nivoTheme = { const nivoTheme = {
background: "transparent", background: "transparent",
text: { fill: "#9CA3AF", fontSize: 11 }, text: { fill: chartPalette.textMuted, fontSize: 11 },
tooltip: { tooltip: {
container: { container: {
background: "#1A1A26", background: chartPalette.canvas,
color: "#E5E7EB", color: chartPalette.text,
fontSize: 12, fontSize: 12,
border: "1px solid #374151", border: `1px solid ${chartPalette.grid}`,
}, },
}, },
labels: { text: { fill: "#D1D5DB" } }, labels: { text: { fill: chartPalette.text } },
}; };
export function SankeyWidget({ export function SankeyWidget({
@@ -1,27 +1,76 @@
"use client"; "use client";
import { ResponsiveBar } from "@nivo/bar"; import { ResponsiveBar } from "@nivo/bar";
import { chartPalette } from "../../lib/chart-theme";
import type { WaterfallStep } from "./types"; import type { WaterfallStep } from "./types";
const barTheme = { const barTheme = {
background: "transparent", background: "transparent",
text: { fill: "#9CA3AF", fontSize: 11 }, text: { fill: chartPalette.textMuted, fontSize: 11 },
axis: { axis: {
domain: { line: { stroke: "#374151" } }, domain: { line: { stroke: chartPalette.grid } },
ticks: { line: { stroke: "#374151" }, text: { fill: "#9CA3AF" } }, ticks: { line: { stroke: chartPalette.grid }, text: { fill: chartPalette.textMuted } },
legend: { text: { fill: "#9CA3AF" } }, legend: { text: { fill: chartPalette.textMuted } },
}, },
grid: { line: { stroke: "#2D2D3A" } }, grid: { line: { stroke: chartPalette.grid } },
tooltip: { tooltip: {
container: { container: {
background: "#1A1A26", background: chartPalette.canvas,
color: "#E5E7EB", color: chartPalette.text,
fontSize: 12, 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[] }) { export function WaterfallWidget({ steps }: { steps: WaterfallStep[] }) {
if (!steps.length) { if (!steps.length) {
return ( return (
@@ -37,6 +86,7 @@ export function WaterfallWidget({ steps }: { steps: WaterfallStep[] }) {
delta: s.value, delta: s.value,
type: s.step_type, type: s.step_type,
})); }));
const tickValues = buildTickValues(data.map((d) => d.delta));
return ( return (
<div className="w-full h-[min(380px,50vh)] min-h-[240px]"> <div className="w-full h-[min(380px,50vh)] min-h-[240px]">
@@ -45,32 +95,26 @@ export function WaterfallWidget({ steps }: { steps: WaterfallStep[] }) {
keys={["delta"]} keys={["delta"]}
indexBy="label" indexBy="label"
layout="horizontal" layout="horizontal"
margin={{ top: 8, right: 28, bottom: 40, left: 160 }} margin={{ top: 8, right: 28, bottom: 56, left: 160 }}
padding={0.35} padding={0.35}
valueScale={{ type: "linear" }} valueScale={{ type: "linear" }}
indexScale={{ type: "band", round: true }} indexScale={{ type: "band", round: true }}
colors={({ data: row }) => { colors={({ data: row }) => {
const r = row as { type?: string; delta?: number }; const r = row as { type?: string; delta?: number };
if (r.type === "total") return "#6366F1"; if (r.type === "total") return chartPalette.navy;
return (r.delta ?? 0) >= 0 ? "#00D4AA" : "#F87171"; return (r.delta ?? 0) >= 0 ? chartPalette.green : chartPalette.red;
}} }}
borderRadius={2} borderRadius={2}
axisTop={null} axisTop={null}
axisRight={null} axisRight={null}
axisBottom={{ axisBottom={{
tickSize: 0, tickSize: 0,
tickPadding: 8, tickPadding: 6,
tickValues,
legend: "USD (reported units)", legend: "USD (reported units)",
legendPosition: "middle", legendPosition: "middle",
legendOffset: 32, legendOffset: 44,
format: (v) => { format: (v) => formatCompactNumber(Number(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();
},
}} }}
axisLeft={{ axisLeft={{
tickSize: 0, tickSize: 0,
@@ -87,7 +131,7 @@ export function WaterfallWidget({ steps }: { steps: WaterfallStep[] }) {
}} }}
labelSkipWidth={12} labelSkipWidth={12}
labelSkipHeight={12} labelSkipHeight={12}
labelTextColor="#E5E7EB" labelTextColor={chartPalette.text}
theme={barTheme} theme={barTheme}
tooltip={({ value, indexValue }) => ( tooltip={({ value, indexValue }) => (
<div className="px-2 py-1 text-xs"> <div className="px-2 py-1 text-xs">
@@ -1,100 +1,97 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useState, useEffect } from "react"; import { BarChart3, Briefcase, CalendarRange, FileSearch, FileText, Globe, Landmark, LineChart, Microscope, Newspaper, Search, Settings, Sparkles, Target, TrendingUp } from "lucide-react";
import { normalizeTickerInput } from "../lib/ticker-alias"; import { useState } from "react";
import { useTicker } from "../lib/use-ticker";
const NAV_ITEMS = [ const NAV_ITEMS = [
{ href: "/", label: "Overview", icon: "📊" }, { href: "/", label: "Overview", icon: BarChart3 },
{ href: "/research", label: "Research", icon: "🔬" }, { href: "/research", label: "Research", icon: Microscope },
{ href: "/valuation", label: "Valuation", icon: "💰" }, { href: "/valuation", label: "Valuation", icon: Landmark },
{ href: "/technical", label: "Technical", icon: "📈" }, { href: "/technical", label: "Technical", icon: TrendingUp },
{ href: "/markets", label: "Markets", icon: "🌍" }, { href: "/markets", label: "Markets", icon: Globe },
{ href: "/macro", label: "Macro", icon: "🌐" }, { href: "/macro", label: "Macro", icon: LineChart },
{ href: "/earnings", label: "Earnings", icon: "📅" }, { href: "/earnings", label: "Earnings", icon: CalendarRange },
{ href: "/news", label: "News", icon: "📰" }, { href: "/news", label: "News", icon: Newspaper },
{ href: "/screener", label: "Screener", icon: "🎯" }, { href: "/screener", label: "Screener", icon: Target },
{ href: "/portfolio", label: "Portfolio", icon: "💼" }, { href: "/portfolio", label: "Portfolio", icon: Briefcase },
{ href: "/filings", label: "Filings", icon: "📑" }, { href: "/filings", label: "Filings", icon: FileSearch },
{ href: "/report", label: "Report", icon: "🏦" }, { href: "/report", label: "Report", icon: FileText },
]; ];
export function Sidebar() { export function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [ticker, setTickerLocal] = useState("AAPL"); const { ticker, setTicker } = useTicker();
useEffect(() => {
const saved = localStorage.getItem("atlas_active_ticker");
if (saved) setTickerLocal(saved);
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail) setTickerLocal(detail);
};
window.addEventListener("atlas-ticker-change", handler);
return () => window.removeEventListener("atlas-ticker-change", handler);
}, []);
function handleSearch() { function handleSearch() {
const val = normalizeTickerInput(input); const val = input.trim();
if (val) { if (val) {
setTickerLocal(val); setTicker(val);
localStorage.setItem("atlas_active_ticker", val);
window.dispatchEvent(new CustomEvent("atlas-ticker-change", { detail: val }));
setInput(""); setInput("");
} }
} }
return ( 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"> <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">
{/* Ticker Search */} <div className="rounded-md border border-border bg-surface-raised p-3 shadow-card">
<div> <div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2 bg-bg-card border border-border rounded-lg px-3.5 py-2.5"> <div>
<span className="text-text-muted">🔍</span> <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 <input
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()} onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="Search ticker..." 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>
<div className="mt-2 px-3.5 py-1.5 bg-bg-card rounded-md flex items-center justify-between"> <div className="mt-3 flex items-center justify-between rounded-md bg-brand-navy px-3 py-2 text-white">
<span className="text-text-muted text-sm">Active:</span> <span className="text-xs uppercase tracking-[0.12em] text-white/70">Ticker</span>
<span className="text-accent-green font-mono font-bold">{ticker}</span> <span className="font-mono text-sm font-bold text-brand-gold">{ticker}</span>
</div> </div>
</div> </div>
{/* Navigation */} <nav className="mt-3 flex flex-col gap-1">
<nav className="flex flex-col gap-1 mt-3">
{NAV_ITEMS.map((item) => { {NAV_ITEMS.map((item) => {
const active = pathname === item.href; const active = pathname === item.href;
const Icon = item.icon;
return ( return (
<Link key={item.href} href={item.href} className="no-underline"> <Link key={item.href} href={item.href} className="no-underline">
<div <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 active
? "bg-accent-green/10 text-accent-green font-semibold border-accent-green" ? "border-brand-gold bg-surface-sunken font-semibold text-brand-navy"
: "text-text-secondary font-normal border-transparent hover:bg-bg-card" : "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> </div>
</Link> </Link>
); );
})} })}
<div className="border-t border-border my-2" /> <div className="my-2 border-t border-border" />
<Link href="/settings" className="no-underline"> <Link href="/settings" className="no-underline">
<div <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" pathname === "/settings"
? "bg-accent-green/10 text-accent-green font-semibold border-accent-green" ? "border-brand-gold bg-surface-sunken font-semibold text-brand-navy"
: "text-text-secondary font-normal border-transparent hover:bg-bg-card" : "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> </div>
</Link> </Link>
</nav> </nav>
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Dot } from "lucide-react";
interface IndexData { interface IndexData {
label: string; label: string;
@@ -41,17 +42,21 @@ export function TickerBar() {
}, []); }, []);
return ( 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"> <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="font-mono font-bold text-accent-green text-lg mr-5"> <div className="mr-4 flex items-baseline gap-2">
ATLAS<span className="text-text-secondary font-normal"> TERMINAL</span> <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>
<div className="flex gap-5 overflow-hidden"> <div className="flex gap-5 overflow-hidden">
{data.map((idx) => ( {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-muted">{idx.label}</span>
<span className="text-text-primary font-semibold">{idx.price}</span> <span className="text-text-primary font-semibold">{idx.price}</span>
{idx.change !== "—" && ( {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} {idx.change}
</span> </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"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Info } from "lucide-react";
import { import {
FilingsViewer, FilingsViewer,
type FilingsViewerHandle, type FilingsViewerHandle,
@@ -330,8 +331,9 @@ export default function FilingsPage() {
</div> </div>
)} )}
{error && ( {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"> <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">
{error} <Info className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</div> </div>
)} )}
{loading && ( {loading && (
+158 -111
View File
@@ -2,6 +2,25 @@
@tailwind components; @tailwind components;
@tailwind utilities; @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; box-sizing: border-box;
margin: 0; margin: 0;
@@ -13,40 +32,53 @@ html {
line-height: 1.5; line-height: 1.5;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
background: var(--surface-canvas);
} }
body { body {
background: #0A0A0F; background: radial-gradient(circle at top left, rgba(196, 163, 90, 0.08), transparent 28%),
color: #F3F4F6; linear-gradient(180deg, #fcfcfd 0%, var(--surface-canvas) 100%);
font-family: "Inter", system-ui, sans-serif; color: var(--text-primary);
font-family: var(--font-sans), system-ui, sans-serif;
} }
:root { h1,
--text-primary: #f3f4f6; h2 {
--text-secondary: #9ca3af; font-family: var(--font-serif), Georgia, serif;
--border-color: #2a2a3a; }
--bg-card: #1a1a26;
::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 { .sec-viewer-container {
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.65; line-height: 1.65;
color: var(--text-primary); color: var(--text-primary);
background: transparent; background: var(--surface-raised);
} }
.sec-viewer-container [id^="sec-item-"] { .sec-viewer-container [id^="sec-item-"] {
scroll-margin-top: 0.75rem; scroll-margin-top: 0.75rem;
} }
.sec-viewer-container,
.sec-viewer-container * {
background-color: transparent !important;
color: var(--text-primary) !important;
}
.sec-viewer-container a { .sec-viewer-container a {
color: #4da6ff !important; color: var(--brand-blue) !important;
text-decoration: underline; text-decoration: underline;
} }
@@ -54,6 +86,7 @@ body {
border-collapse: collapse; border-collapse: collapse;
width: max-content; width: max-content;
max-width: none; max-width: none;
background: var(--surface-raised);
} }
.sec-viewer-container th, .sec-viewer-container th,
@@ -61,6 +94,7 @@ body {
border: 1px solid var(--border-color) !important; border: 1px solid var(--border-color) !important;
padding: 0.35rem 0.5rem !important; padding: 0.35rem 0.5rem !important;
vertical-align: top; vertical-align: top;
color: var(--text-primary) !important;
} }
.sec-viewer-container img { .sec-viewer-container img {
@@ -69,144 +103,128 @@ body {
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 8px;
} height: 8px;
::-webkit-scrollbar-track {
background: #0A0A0F;
}
::-webkit-scrollbar-thumb {
background: #2A2A3A;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #3A3A4A;
} }
input::placeholder { ::-webkit-scrollbar-track {
color: #6B7280; 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 { .action-btn {
width: 32px; width: 32px;
height: 32px; height: 32px;
border: none; border: 1px solid transparent;
border-radius: 6px; border-radius: 8px;
background: transparent; background: transparent;
cursor: pointer; 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 { .edit-input {
width: 100%; width: 100%;
max-width: 120px; max-width: 120px;
background: #0A0A0F; background: var(--surface-raised);
border: 1px solid #4DA6FF; border: 1px solid var(--brand-blue);
border-radius: 6px; border-radius: 8px;
padding: 6px 10px; padding: 6px 10px;
color: #F3F4F6; color: var(--text-primary);
font-family: "JetBrains Mono", monospace; font-family: var(--font-mono), monospace;
font-size: 13px; font-size: 13px;
outline: none; outline: none;
} }
.delete-modal-overlay { .delete-modal-overlay {
position: fixed; position: fixed;
top: 0; inset: 0;
left: 0; background: rgba(27, 42, 74, 0.24);
right: 0; backdrop-filter: blur(4px);
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1000; z-index: 1000;
} }
.portfolio-header { .portfolio-header,
.heatmap-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 24px; margin-bottom: 24px;
} }
.currency-toggle { .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;
}
.index-toggle { .index-toggle {
display: flex; display: flex;
gap: 4px; gap: 4px;
background: #1A1A26; background: var(--surface-sunken);
border: 1px solid #2A2A3A; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 10px;
padding: 3px; padding: 3px;
} }
.currency-btn,
.index-btn { .index-btn {
padding: 6px 16px; padding: 6px 14px;
border: none; border: none;
border-radius: 6px; border-radius: 8px;
background: transparent; background: transparent;
color: #9CA3AF; color: var(--text-secondary);
font-size: 13px; font-family: var(--font-mono), monospace;
font-weight: 500; font-size: 12px;
font-weight: 600;
cursor: pointer; 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 { .index-btn:hover {
color: #F3F4F6; color: var(--brand-navy);
background: #252536; background: rgba(27, 42, 74, 0.06);
} }
.currency-btn.active,
.index-btn.active { .index-btn.active {
background: #00D4AA; background: var(--brand-navy);
color: #0A0A0F; color: #ffffff;
} }
.treemap-container { .treemap-container {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 2px; gap: 2px;
border-radius: 8px; border-radius: 12px;
overflow: hidden; overflow: hidden;
min-height: 400px; min-height: 400px;
background: #0A0A0F; background: var(--surface-sunken);
border: 1px solid var(--border-color);
} }
.treemap-sector { .treemap-sector {
@@ -218,15 +236,15 @@ input::placeholder {
.treemap-sector-label { .treemap-sector-label {
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 700;
color: #9CA3AF; color: var(--brand-navy);
padding: 4px 6px; padding: 4px 6px;
background: rgba(0, 0, 0, 0.3); background: rgba(255, 255, 255, 0.84);
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
z-index: 1; z-index: 1;
border-radius: 4px 0 4px 0; border-radius: 6px 0 6px 0;
pointer-events: none; pointer-events: none;
} }
@@ -246,34 +264,37 @@ input::placeholder {
min-height: 50px; min-height: 50px;
padding: 4px; padding: 4px;
cursor: pointer; cursor: pointer;
transition: filter 0.15s, transform 0.1s; transition: filter 0.15s ease, transform 0.1s ease;
border-radius: 2px; border-radius: 2px;
} }
.treemap-cell:hover { .treemap-cell:hover {
filter: brightness(1.15); filter: brightness(1.04);
transform: scale(1.02); transform: scale(1.01);
z-index: 2; z-index: 2;
} }
.treemap-ticker,
.treemap-change,
.treemap-subticker {
font-family: var(--font-mono), monospace;
}
.treemap-ticker { .treemap-ticker {
font-family: "JetBrains Mono", monospace;
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
color: #F3F4F6; color: #ffffff;
} }
.treemap-change { .treemap-change {
font-family: "JetBrains Mono", monospace;
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
color: #F3F4F6; color: rgba(255, 255, 255, 0.96);
} }
.treemap-subticker { .treemap-subticker {
font-family: "JetBrains Mono", monospace;
font-size: 9px; font-size: 9px;
color: #9CA3AF; color: rgba(255, 255, 255, 0.82);
} }
.heatmap-loading { .heatmap-loading {
@@ -281,6 +302,32 @@ input::placeholder {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 400px; min-height: 400px;
color: #9CA3AF; color: var(--text-muted);
font-size: 14px; 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 type { Metadata } from "next";
import { Inter, JetBrains_Mono, Source_Serif_4 } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { AppShell } from "./components/app-shell"; import { AppShell } from "./components/app-shell";
@@ -7,17 +8,31 @@ export const metadata: Metadata = {
description: "Advanced Trading & Liquidity Analysis System", 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 }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en"> <html lang="en">
<head> <body
<link rel="preconnect" href="https://fonts.googleapis.com" /> className={`${inter.variable} ${sourceSerif.variable} ${jetBrainsMono.variable} min-h-screen antialiased`}
<link suppressHydrationWarning
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>
<AppShell>{children}</AppShell> <AppShell>{children}</AppShell>
</body> </body>
</html> </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 { GlobalMacroQuadrantChart, type QuadrantPoint } from "../components/macro/GlobalMacroQuadrantChart";
import { YieldFxDualAxisChart, type YieldFxRow } from "../components/macro/YieldFxDualAxisChart"; import { YieldFxDualAxisChart, type YieldFxRow } from "../components/macro/YieldFxDualAxisChart";
import { SmartMoneyPanel } from "../components/macro/SmartMoneyPanel"; import { SmartMoneyPanel } from "../components/macro/SmartMoneyPanel";
import { ErrorBanner } from "../components/ui/ErrorBanner";
import { LoadingPulse } from "../components/ui/LoadingPulse";
const FRED_PRESETS = [ const FRED_PRESETS = [
{ id: "UNRATE", label: "US Unemployment %" }, { id: "UNRATE", label: "US Unemployment %" },
@@ -40,6 +42,30 @@ interface SubfactorData {
categories: Record<string, { score: number; indicators: SubfactorIndicator[] }>; 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() { export default function MacroPage() {
const [series, setSeries] = useState("UNRATE"); const [series, setSeries] = useState("UNRATE");
const [rows, setRows] = useState<FredPoint[]>([]); const [rows, setRows] = useState<FredPoint[]>([]);
@@ -55,10 +81,12 @@ export default function MacroPage() {
const [subfactors, setSubfactors] = useState<SubfactorData | null>(null); const [subfactors, setSubfactors] = useState<SubfactorData | null>(null);
const [quadPoints, setQuadPoints] = useState<QuadrantPoint[]>([]); const [quadPoints, setQuadPoints] = useState<QuadrantPoint[]>([]);
const [quadLoading, setQuadLoading] = useState(true);
const [quadErr, setQuadErr] = useState<string | null>(null); const [quadErr, setQuadErr] = useState<string | null>(null);
const [yieldPair, setYieldPair] = useState<"usdjpy" | "eurusd" | "usdkrw">("usdjpy"); const [yieldPair, setYieldPair] = useState<"usdjpy" | "eurusd" | "usdkrw">("usdjpy");
const [yieldRows, setYieldRows] = useState<YieldFxRow[]>([]); const [yieldRows, setYieldRows] = useState<YieldFxRow[]>([]);
const [yieldLoading, setYieldLoading] = useState(true);
const [yieldErr, setYieldErr] = useState<string | null>(null); const [yieldErr, setYieldErr] = useState<string | null>(null);
const [roroZ, setRoroZ] = useState<number | null>(null); const [roroZ, setRoroZ] = useState<number | null>(null);
@@ -66,43 +94,60 @@ export default function MacroPage() {
const [copperGold, setCopperGold] = useState< const [copperGold, setCopperGold] = useState<
{ date: string; ratio: number; ratio_ma20?: number }[] { date: string; ratio: number; ratio_ma20?: number }[]
>([]); >([]);
const [smartLoading, setSmartLoading] = useState(true);
const [smartErr, setSmartErr] = useState<string | null>(null); const [smartErr, setSmartErr] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
setErr(null); setErr(null);
fetch(`/api/macro/fred/${encodeURIComponent(series)}`) const controller = new AbortController();
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))) fetch(`/api/macro/fred/${encodeURIComponent(series)}`, { signal: controller.signal })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP_${r.status}`))))
.then((j) => { .then((j) => {
setRows(Array.isArray(j.data) ? j.data : []); setRows(Array.isArray(j.data) ? j.data : []);
setLoading(false); setLoading(false);
}) })
.catch(() => { .catch((error: unknown) => {
setErr("Failed to load FRED series."); if (controller.signal.aborted) return;
setErr(formatMacroError(error instanceof Error ? error.message : error, "Failed to load FRED series."));
setLoading(false); setLoading(false);
}); });
return () => controller.abort();
}, [series]); }, [series]);
useEffect(() => { useEffect(() => {
fetch("/api/macro/snapshot") setSnapErr(null);
.then((r) => (r.ok ? r.json() : 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) => { .then((j) => {
if (!j || typeof j !== "object") { if (!j || typeof j !== "object") {
setSnap(null); setSnap(null);
setSnapErr("Macro snapshot returned an empty payload.");
return; 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); else setSnapErr(null);
setSnap({ setSnap({
updated_at: j.updated_at ?? null, updated_at: j.updated_at ?? null,
cycle_score: typeof j.cycle_score === "number" ? j.cycle_score : 0, cycle_score: typeof j.cycle_score === "number" ? j.cycle_score : 0,
regime: String(j.regime ?? "—"), regime: String(j.regime ?? "—"),
cycle_heatmap: Array.isArray(j.cycle_heatmap) ? j.cycle_heatmap : [], cycle_heatmap: cycleHeatmap,
country_heatmap: Array.isArray(j.country_heatmap) ? j.country_heatmap : [], country_heatmap: countryHeatmap,
asset_valuation: Array.isArray(j.asset_valuation) ? j.asset_valuation : [], 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(() => { useEffect(() => {
@@ -123,55 +168,100 @@ export default function MacroPage() {
useEffect(() => { useEffect(() => {
setQuadErr(null); setQuadErr(null);
fetch("/api/macro/quadrant") setQuadLoading(true);
.then((r) => (r.ok ? r.json() : null)) 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) => { .then((j) => {
if (!j || typeof j !== "object") { if (!j || typeof j !== "object") {
setQuadPoints([]); setQuadPoints([]);
setQuadErr("Macro quadrant returned an empty payload.");
return; return;
} }
if (j.error) setQuadErr(String(j.error)); const points = Array.isArray(j.points) ? j.points : [];
setQuadPoints(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(() => { useEffect(() => {
setYieldErr(null); setYieldErr(null);
fetch(`/api/macro/yield-fx?pair=${encodeURIComponent(yieldPair)}`) setYieldLoading(true);
.then((r) => (r.ok ? r.json() : null)) 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) => { .then((j) => {
if (!j || typeof j !== "object") { if (!j || typeof j !== "object") {
setYieldRows([]); setYieldRows([]);
setYieldErr("Yield/FX returned an empty payload.");
return; return;
} }
if (j.error) setYieldErr(String(j.error)); const seriesRows = Array.isArray(j.series) ? j.series : [];
setYieldRows(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]); }, [yieldPair]);
useEffect(() => { useEffect(() => {
setSmartErr(null); setSmartErr(null);
fetch("/api/macro/smart-money") setSmartLoading(true);
.then((r) => (r.ok ? r.json() : null)) 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) => { .then((j) => {
if (!j || typeof j !== "object") { if (!j || typeof j !== "object") {
setRoroZ(null); setRoroZ(null);
setRoroLabel(null); setRoroLabel(null);
setCopperGold([]); setCopperGold([]);
setSmartErr("Smart money returned an empty payload.");
return; return;
} }
if (j.error) setSmartErr(String(j.error)); const nextRoroZ = typeof j.roro_z === "number" ? j.roro_z : null;
setRoroZ(typeof j.roro_z === "number" ? j.roro_z : null); const nextRoroLabel = j.roro_label != null ? String(j.roro_label) : null;
setRoroLabel(j.roro_label != null ? String(j.roro_label) : null); const nextCopperGold = Array.isArray(j.copper_gold) ? j.copper_gold : [];
setCopperGold(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); setRoroZ(null);
setRoroLabel(null); setRoroLabel(null);
setCopperGold([]); 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 }[] = [ 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"> <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> <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> <p className="text-text-muted text-xs mb-3">3M momentum Z-scores (growth vs inflation).</p>
{quadErr && ( <ErrorBanner message={quadErr} variant="warning" className="mb-3" />
<div className="text-accent-yellow text-xs font-mono mb-2">Warning: {quadErr}</div> {quadLoading ? (
<LoadingPulse label="Loading macro quadrant…" height="h-[320px]" />
) : (
<GlobalMacroQuadrantChart points={quadPoints} />
)} )}
<GlobalMacroQuadrantChart points={quadPoints} />
</div> </div>
<div className="lg:col-span-6 bg-bg-secondary/30 border border-border/60 rounded-lg p-4"> <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>
</div> </div>
{yieldErr && ( <ErrorBanner message={yieldErr} variant="warning" className="mb-3" />
<div className="text-accent-yellow text-xs font-mono mb-2">Warning: {yieldErr}</div> {yieldLoading ? (
<LoadingPulse label="Loading yield/FX series…" height="h-[280px]" />
) : (
<YieldFxDualAxisChart pair={yieldPair} rows={yieldRows} />
)} )}
<YieldFxDualAxisChart pair={yieldPair} rows={yieldRows} />
</div> </div>
<div className="lg:col-span-6 bg-bg-secondary/30 border border-border/60 rounded-lg p-4"> <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> <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> <p className="text-text-muted text-xs mb-3">HG/GC ratio and composite risk Z-score.</p>
{smartErr && ( <ErrorBanner message={smartErr} variant="warning" className="mb-3" />
<div className="text-accent-yellow text-xs font-mono mb-2">Warning: {smartErr}</div> {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> </div>
</div> </div>
@@ -287,9 +383,9 @@ export default function MacroPage() {
</div> </div>
{loading ? ( {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 ? ( ) : 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="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"> <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" && ( {tab === "cycle" && (
<div className="space-y-4"> <div className="space-y-4">
{snapErr && ( <ErrorBanner message={snapErr ? `Snapshot warning: ${snapErr}` : null} variant="warning" />
<div className="text-accent-yellow border border-accent-yellow/30 rounded-lg p-3 text-sm">
Snapshot warning: {snapErr}
</div>
)}
<MacroCycleHeatmap data={snap} /> <MacroCycleHeatmap data={snap} />
</div> </div>
)} )}
+43 -41
View File
@@ -1,55 +1,57 @@
"use client"; "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 { EquityOverview } from "./components/overview/EquityOverview";
import { ETFOverview } from "./components/overview/ETFOverview"; 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() { export default function OverviewPage() {
const { ticker, initialized } = useTicker(); 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(() => { const sectorUrl = initialized ? `/api/market/sector/${ticker}` : null;
if (!initialized) return; const healthUrl = initialized ? `/api/market/health/${ticker}` : null;
const ac = new AbortController(); const overviewUrl = initialized ? `/api/market/overview/${ticker}` : null;
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]);
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") { if (assetType === "etf") {
return <ETFOverview ticker={ticker} data={overview || {}} />; return <ETFOverview ticker={ticker} data={overviewData} />;
} }
if (assetType === "commodity_future") { if (assetType === "commodity_future") {
return <CommodityOverview ticker={ticker} data={overview || {}} />; return <CommodityOverview ticker={ticker} data={overviewData} />;
} }
return <EquityOverview ticker={ticker} sector={sector} health={health} />; return <EquityOverview ticker={ticker} sector={sector.data} health={health.data} />;
}
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>
);
} }
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { AlertTriangle, Check, CheckCircle2, CircleX, Pencil, Trash2 } from "lucide-react";
interface Position { interface Position {
id?: string; id?: string;
@@ -474,7 +475,13 @@ export default function PortfolioPage() {
{p.pnl_pct != null ? `${p.pnl_pct >= 0 ? "+" : ""}${p.pnl_pct.toFixed(2)}%` : "—"} {p.pnl_pct != null ? `${p.pnl_pct >= 0 ? "+" : ""}${p.pnl_pct.toFixed(2)}%` : "—"}
</td> </td>
<td className="px-3 py-2"> <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> </td>
</> </>
); );
@@ -491,8 +498,9 @@ export default function PortfolioPage() {
> >
Cancel Cancel
</button> </button>
<button onClick={importOcrPositions} className="bg-accent-green text-bg-primary px-5 py-2 rounded-md font-semibold hover:opacity-90 transition-opacity"> <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">
Add All to Portfolio <Check className="h-4 w-4" />
Add All to Portfolio
</button> </button>
</div> </div>
</div> </div>
@@ -564,8 +572,12 @@ export default function PortfolioPage() {
<td className="px-4 py-2.5 text-right"> <td className="px-4 py-2.5 text-right">
{editingId === pid ? ( {editingId === pid ? (
<div className="flex gap-1 justify-end"> <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={() => handleSaveEdit(pid)} className="flex h-8 w-8 items-center justify-center rounded bg-accent-green/20 hover:bg-accent-green/30">
<button onClick={() => setEditingId(null)} className="w-8 h-8 rounded bg-bg-primary hover:bg-bg-hover"></button> <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>
) : ( ) : (
<div className="flex gap-1 justify-end"> <div className="flex gap-1 justify-end">
@@ -574,17 +586,17 @@ export default function PortfolioPage() {
setEditingId(pid); setEditingId(pid);
setEditValues({ qty: String(p.quantity), avgPrice: String(p.avg_price) }); 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" title="Edit position"
> >
<Pencil className="h-4 w-4" />
</button> </button>
<button <button
onClick={() => setDeleteConfirmId(pid)} 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" title="Delete position"
> >
🗑 <Trash2 className="h-4 w-4" />
</button> </button>
</div> </div>
)} )}
@@ -0,0 +1,291 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Financial performance + quality / risk sections.
* Extracted from the original /report monolith.
*/
import {
BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid,
Tooltip, ResponsiveContainer, Cell, Legend,
} from "recharts";
import { C } from "../design-tokens";
import { getValue } from "../lib/formatters";
import type { FinancialPeriod, ResearchDash, HealthData, StatementsBundle } from "../types";
export function FinancialCharts({ statements }: { statements: StatementsBundle }) {
const is = statements.income_statement;
if (!is || is.length === 0) return null;
const chartData = is.slice(0, 5).reverse().map((p: FinancialPeriod) => {
const period = p as Record<string, unknown>;
const rev = getValue(period, "TotalRevenue|Total Revenue|Revenue");
const ni = getValue(period, "NetIncome|Net Income|Net Income Common Stockholders");
const gp = getValue(period, "GrossProfit|Gross Profit");
const op = getValue(period, "OperatingIncome|Operating Income");
const yr = (period.asOfDate || period.fiscalYear || period.year || "") as string | number;
return {
year: typeof yr === "string" ? yr.slice(0, 4) : String(yr),
revenue: rev ? rev / 1e9 : 0,
netIncome: ni ? ni / 1e9 : 0,
grossMargin: rev && gp ? (gp / rev) * 100 : 0,
opMargin: rev && op ? (op / rev) * 100 : 0,
netMargin: rev && ni ? (ni / rev) * 100 : 0,
};
});
return (
<div className="report-section">
<h2 className="section-title">Income Statement Trends</h2>
<div className="grid grid-cols-2 gap-6">
<div>
<h3 className="chart-title">Revenue & Net Income ($B)</h3>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" />
<XAxis dataKey="year" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip />
<Bar dataKey="revenue" fill={C.blue} name="Revenue" radius={[2, 2, 0, 0]} />
<Bar dataKey="netIncome" fill={C.gold} name="Net Income" radius={[2, 2, 0, 0]} />
<Legend wrapperStyle={{ fontSize: 10 }} />
</BarChart>
</ResponsiveContainer>
</div>
<div>
<h3 className="chart-title">Margin Trends (%)</h3>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" />
<XAxis dataKey="year" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip formatter={(value: number | string) => `${Number(value).toFixed(1)}%`} />
<Line type="monotone" dataKey="grossMargin" stroke={C.green} name="Gross" strokeWidth={2} dot={{ r: 3 }} />
<Line type="monotone" dataKey="opMargin" stroke={C.blue} name="Operating" strokeWidth={2} dot={{ r: 3 }} />
<Line type="monotone" dataKey="netMargin" stroke={C.gold} name="Net" strokeWidth={2} dot={{ r: 3 }} />
<Legend wrapperStyle={{ fontSize: 10 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}
export function BalanceSheetCashFlow({ statements }: { statements: StatementsBundle }) {
const bs = statements.balance_sheet;
const cf = statements.cash_flow;
if ((!bs || bs.length === 0) && (!cf || cf.length === 0)) return null;
const bsData = (bs || []).slice(0, 5).reverse().map((p: FinancialPeriod) => {
const period = p as Record<string, unknown>;
const ta = getValue(period, "TotalAssets|Total Assets");
const tl = getValue(period, "TotalLiabilitiesNetMinorityInterest|Total Liabilities Net Minority Interest|TotalLiab|Total Liabilities");
const te = getValue(period, "StockholdersEquity|Stockholders Equity|TotalStockholderEquity|Total Stockholder Equity");
const cash = getValue(period, "CashAndCashEquivalents|Cash And Cash Equivalents|CashCashEquivalentsAndShortTermInvestments");
const yr = (period.asOfDate || period.fiscalYear || "") as string | number;
return {
year: typeof yr === "string" ? yr.slice(0, 4) : String(yr),
assets: ta ? ta / 1e9 : 0,
liabilities: tl ? tl / 1e9 : 0,
equity: te ? te / 1e9 : 0,
cash: cash ? cash / 1e9 : 0,
};
});
const cfData = (cf || []).slice(0, 5).reverse().map((p: FinancialPeriod) => {
const period = p as Record<string, unknown>;
const ocf = getValue(period, "OperatingCashFlow|Operating Cash Flow|CashFlowFromContinuingOperatingActivities");
const capex = getValue(period, "CapitalExpenditure|Capital Expenditure");
const fcf = getValue(period, "FreeCashFlow|Free Cash Flow") ?? (ocf != null && capex != null ? ocf + capex : null);
const yr = (period.asOfDate || period.fiscalYear || "") as string | number;
return {
year: typeof yr === "string" ? yr.slice(0, 4) : String(yr),
ocf: ocf ? ocf / 1e9 : 0,
capex: capex ? Math.abs(capex) / 1e9 : 0,
fcf: fcf ? fcf / 1e9 : 0,
};
});
return (
<div className="report-section">
<div className="grid grid-cols-2 gap-6">
{bsData.length > 0 && (
<div>
<h3 className="chart-title">Balance Sheet ($B)</h3>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={bsData}>
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" />
<XAxis dataKey="year" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip />
<Bar dataKey="assets" fill={C.blue} name="Total Assets" radius={[2, 2, 0, 0]} />
<Bar dataKey="equity" fill={C.green} name="Equity" radius={[2, 2, 0, 0]} />
<Bar dataKey="cash" fill={C.gold} name="Cash" radius={[2, 2, 0, 0]} />
<Legend wrapperStyle={{ fontSize: 10 }} />
</BarChart>
</ResponsiveContainer>
</div>
)}
{cfData.length > 0 && (
<div>
<h3 className="chart-title">Cash Flow ($B)</h3>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={cfData}>
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" />
<XAxis dataKey="year" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip />
<Bar dataKey="ocf" fill={C.blue} name="Operating CF" radius={[2, 2, 0, 0]} />
<Bar dataKey="fcf" fill={C.green} name="Free CF" radius={[2, 2, 0, 0]} />
<Bar dataKey="capex" fill={C.red} name="CapEx" radius={[2, 2, 0, 0]} />
<Legend wrapperStyle={{ fontSize: 10 }} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</div>
</div>
);
}
export function QualityScores({ research, health }: { research: ResearchDash | null; health: HealthData | null }) {
if (!research && !health) return null;
const { fscore_total = 0, fscore_criteria = [], dupont_tree } = research || {};
const altmanZ = health?.altman_z;
const zLabel = altmanZ == null ? "N/A" : altmanZ > 2.99 ? "Safe" : altmanZ > 1.81 ? "Gray Zone" : "Distress";
const zColor = altmanZ == null ? C.muted : altmanZ > 2.99 ? C.green : altmanZ > 1.81 ? C.gold : C.red;
return (
<div className="report-section">
<h2 className="section-title">Quality Assessment</h2>
<div className="grid grid-cols-3 gap-6">
<div>
<h3 className="chart-title">Piotroski F-Score: {fscore_total}/9</h3>
<div className="flex items-center gap-1 mb-3">
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className="h-6 flex-1 rounded-sm flex items-center justify-center text-[10px] font-bold"
style={{ background: i < fscore_total ? C.green : "#E8ECF0", color: i < fscore_total ? "#fff" : C.muted }}>
{i + 1}
</div>
))}
</div>
<div className="space-y-0.5">
{fscore_criteria.map((c) => (
<div key={c.key} className="flex items-center gap-2 text-[11px]">
<span style={{ color: c.history[0]?.pass_flag ? C.green : C.red }}>{c.history[0]?.pass_flag ? "\u2713" : "\u2717"}</span>
<span style={{ color: C.text }}>{c.label}</span>
</div>
))}
</div>
</div>
<div>
<h3 className="chart-title">Financial Health</h3>
<div className="p-4 rounded mb-3" style={{ background: "#F4F6F9" }}>
<div className="text-center mb-2">
<div className="text-[10px] uppercase" style={{ color: C.muted }}>Altman Z-Score</div>
<div className="text-3xl font-mono font-bold" style={{ color: zColor }}>{altmanZ?.toFixed(2) ?? "N/A"}</div>
<div className="text-[10px] font-bold" style={{ color: zColor }}>{zLabel}</div>
</div>
<div className="space-y-1 mt-3">
{[
{ l: "Current Ratio", v: health?.current_ratio?.toFixed(2) },
{ l: "Interest Coverage", v: health?.interest_coverage?.toFixed(1) },
{ l: "Debt/Equity", v: health?.debt_to_equity?.toFixed(2) },
].map((r) => (
<div key={r.l} className="flex justify-between text-[11px]">
<span style={{ color: C.muted }}>{r.l}</span>
<span className="font-mono" style={{ color: C.navy }}>{r.v ?? "N/A"}</span>
</div>
))}
</div>
</div>
{health?.red_flags && health.red_flags.length > 0 && (
<div>
<div className="text-[10px] uppercase font-bold mb-1" style={{ color: C.red }}>Red Flags</div>
{health.red_flags.slice(0, 4).map((f, i) => (
<div key={i} className="text-[10px] mb-0.5" style={{ color: C.red }}>&bull; {f}</div>
))}
</div>
)}
</div>
{dupont_tree && (
<div>
<h3 className="chart-title">DuPont ROE Decomposition</h3>
<div className="p-4 rounded" style={{ background: "#F4F6F9" }}>
<div className="text-center mb-3">
<div className="text-[10px] uppercase" style={{ color: C.muted }}>Return on Equity</div>
<div className="text-3xl font-mono font-bold" style={{ color: C.navy }}>{dupont_tree.root.value.toFixed(1)}%</div>
</div>
<div className="grid grid-cols-3 gap-2 text-center">
{[
{ label: "Net Margin", val: `${dupont_tree.npm.value.toFixed(1)}%`, trend: dupont_tree.npm.trend },
{ label: "Asset T/O", val: `${dupont_tree.asset_turnover.value.toFixed(2)}x`, trend: dupont_tree.asset_turnover.trend },
{ label: "Equity Mult", val: `${dupont_tree.equity_mult.value.toFixed(2)}x`, trend: dupont_tree.equity_mult.trend },
].map((d) => (
<div key={d.label} className="p-2 rounded" style={{ background: "#fff" }}>
<div className="text-[9px] uppercase" style={{ color: C.muted }}>{d.label}</div>
<div className="text-sm font-mono font-bold" style={{ color: C.navy }}>{d.val}</div>
<div className="text-[9px]" style={{ color: d.trend === "up" ? C.green : d.trend === "down" ? C.red : C.muted }}>
{d.trend === "up" ? "\u25B2" : d.trend === "down" ? "\u25BC" : "\u25C6"}
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
</div>
);
}
export function WaterfallChart({ waterfall }: { waterfall: ResearchDash["waterfall"] }) {
if (!waterfall || waterfall.length === 0) return null;
const data = waterfall.map((w) => ({
name: w.label.length > 14 ? w.label.slice(0, 14) + ".." : w.label,
value: w.value / 1e9,
fill: w.step_type === "total" ? C.navy : w.value >= 0 ? C.green : C.red,
}));
return (
<div className="report-section">
<h2 className="section-title">Operating Income Bridge ($B)</h2>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" />
<XAxis dataKey="name" tick={{ fontSize: 9 }} angle={-20} textAnchor="end" height={50} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip />
<Bar dataKey="value" radius={[2, 2, 0, 0]}>{data.map((d, i) => <Cell key={i} fill={d.fill} />)}</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
}
export function AnomalyTable({ anomalies }: { anomalies: ResearchDash["anomalies"] }) {
if (!anomalies || anomalies.length === 0) return null;
return (
<div className="report-section">
<h2 className="section-title">YoY Anomalies (&gt;30% Change)</h2>
<table className="w-full text-xs">
<thead>
<tr style={{ borderBottom: `2px solid ${C.navy}` }}>
<th className="text-left py-1.5">Line Item</th>
<th className="text-right py-1.5">YoY Change</th>
<th className="text-center py-1.5">Direction</th>
</tr>
</thead>
<tbody>
{anomalies.slice(0, 12).map((a) => (
<tr key={a.account_key} style={{ borderBottom: `1px solid ${C.lightGray}` }}>
<td className="py-1" style={{ color: C.text }}>{a.display_name}</td>
<td className="text-right font-mono" style={{ color: a.direction === "up" ? C.green : C.red }}>{a.change_pct != null ? `${a.change_pct > 0 ? "+" : ""}${a.change_pct.toFixed(1)}%` : "N/A"}</td>
<td className="text-center">{a.direction === "up" ? "\u25B2" : "\u25BC"}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,192 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Cover, TOC, Investment Snapshot (KpiRow + CompanyProfile), Disclaimer.
* Pure presentational components extracted from the original 1400-line page.
*/
import { C } from "../design-tokens";
import { fmtB, fmtPct, fmtPrice, renderMarkdown } from "../lib/formatters";
import type { ConsensusData, DCFResult, RelativeValData, InstitutionalData } from "../types";
export function CoverPage({
ticker, info, consensus, dcf, relativeVal,
}: {
ticker: string;
info: Record<string, any>;
consensus: ConsensusData | null;
dcf: DCFResult | null;
relativeVal: RelativeValData | null;
}) {
const now = new Date();
const dateStr = now.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
const price = info.currentPrice || info.regularMarketPrice || 0;
const target = consensus?.target_mean ?? info.targetMeanPrice ?? 0;
const upside = price > 0 && target > 0 ? ((target - price) / price) * 100 : 0;
const rec = consensus?.recommendation?.toUpperCase() || "N/A";
const recColor = rec.includes("BUY") || rec.includes("STRONG") ? C.green : rec.includes("SELL") ? C.red : C.gold;
return (
<div className="report-page cover-page flex flex-col items-center justify-center text-center">
<div className="mb-10">
<div className="text-sm tracking-[0.3em] uppercase" style={{ color: C.gold }}>ATLAS TERMINAL</div>
<div className="text-xs tracking-[0.2em] uppercase mt-1" style={{ color: C.muted }}>Institutional Equity Research</div>
</div>
<div className="w-24 h-px mb-10" style={{ background: C.gold }} />
<h1 className="text-5xl font-serif font-bold mb-3" style={{ color: C.navy }}>{info.longName || ticker}</h1>
<p className="text-xl mb-1" style={{ color: C.blue }}>{ticker} &mdash; {info.exchange || ""}</p>
<p className="text-base mb-6" style={{ color: C.muted }}>{info.sector || ""} &bull; {info.industry || ""}</p>
<div className="inline-flex items-center gap-3 px-6 py-3 rounded-lg mb-8" style={{ background: "#F4F6F9", border: `2px solid ${recColor}` }}>
<div className="text-xs uppercase tracking-wider" style={{ color: C.muted }}>Consensus</div>
<div className="text-2xl font-bold font-mono" style={{ color: recColor }}>{rec}</div>
<div className="w-px h-8" style={{ background: C.lightGray }} />
<div className="text-right">
<div className="text-xs" style={{ color: C.muted }}>Target</div>
<div className="font-mono font-bold" style={{ color: C.navy }}>{fmtPrice(target)}</div>
</div>
<div className="text-right">
<div className="text-xs" style={{ color: C.muted }}>Upside</div>
<div className="font-mono font-bold" style={{ color: upside >= 0 ? C.green : C.red }}>{fmtPct(upside)}</div>
</div>
</div>
<div className="grid grid-cols-4 gap-6 mb-8" style={{ maxWidth: 640 }}>
{[
{ label: "Price", value: fmtPrice(price) },
{ label: "Market Cap", value: fmtB(info.marketCap) },
{ label: relativeVal ? `${relativeVal.method} Fair Value` : "DCF Fair Value", value: relativeVal ? fmtPrice(relativeVal.base.value) : dcf?.base != null ? fmtPrice(dcf.base) : "N/A" },
{ label: "Analysts", value: consensus ? `${consensus.num_analysts}` : "N/A" },
].map((k) => (
<div key={k.label}>
<div className="text-xs uppercase tracking-wider" style={{ color: C.muted }}>{k.label}</div>
<div className="text-xl font-mono font-bold" style={{ color: C.navy }}>{k.value}</div>
</div>
))}
</div>
<div className="w-24 h-px my-6" style={{ background: C.gold }} />
<p className="text-sm" style={{ color: C.muted }}>{dateStr}</p>
</div>
);
}
export function TableOfContents({ hasInstitutional }: { hasInstitutional: boolean }) {
const sections = [
"Investment Snapshot & Key Metrics",
"Company Profile",
"Financial Performance",
"Balance Sheet & Cash Flow",
"Quality Assessment — F-Score & DuPont",
"Operating Income Bridge",
"YoY Anomalies",
"Valuation — DCF 3-Scenario Analysis",
"Sensitivity Matrix & Monte Carlo",
"Peer Comparison",
"Earnings Analysis",
...(hasInstitutional ? [
"Executive Summary (AI)",
"Goldman Sachs / Morgan Stanley / JP Morgan",
"BlackRock / Bridgewater / Berkshire Hathaway",
"Citadel / Two Sigma / Elliott Mgmt / ARK Invest",
] : []),
"Disclaimer",
];
return (
<div className="report-page">
<div className="page-header">Table of Contents</div>
<div className="space-y-2 mt-4">
{sections.map((s, i) => (
<div key={i} className="flex items-center gap-2 text-sm" style={{ color: C.text }}>
<span className="font-mono text-xs w-6 text-right" style={{ color: C.blue }}>{i + 1}.</span>
<span className="flex-1">{s}</span>
<span className="flex-1 border-b border-dotted" style={{ borderColor: C.lightGray }} />
<span className="font-mono text-xs" style={{ color: C.muted }}>{i + 2}</span>
</div>
))}
</div>
</div>
);
}
export function KpiRow({ info }: { info: Record<string, any> }) {
const kpis = [
{ label: "Revenue", value: fmtB(info.totalRevenue), sub: fmtPct((info.revenueGrowth || 0) * 100) },
{ label: "Net Income", value: fmtB(info.netIncomeToCommon), sub: `Margin ${((info.profitMargins || 0) * 100).toFixed(1)}%` },
{ label: "Free Cash Flow", value: fmtB(info.freeCashflow), sub: `Yield ${info.freeCashflow && info.marketCap ? ((info.freeCashflow / info.marketCap) * 100).toFixed(1) : "N/A"}%` },
{ label: "ROE", value: `${((info.returnOnEquity || 0) * 100).toFixed(1)}%`, sub: `ROA ${((info.returnOnAssets || 0) * 100).toFixed(1)}%` },
{ label: "P/E (TTM)", value: info.trailingPE ? `${info.trailingPE.toFixed(1)}x` : "N/A", sub: `Fwd ${info.forwardPE ? info.forwardPE.toFixed(1) + "x" : "N/A"}` },
{ label: "EV/EBITDA", value: info.enterpriseToEbitda ? `${info.enterpriseToEbitda.toFixed(1)}x` : "N/A", sub: `D/E ${info.debtToEquity ?? "N/A"}` },
];
return (
<div className="grid grid-cols-6 gap-3 mb-6">
{kpis.map((k) => (
<div key={k.label} className="text-center p-3 rounded" style={{ background: "#F4F6F9" }}>
<div className="text-[10px] uppercase tracking-wider mb-1" style={{ color: C.muted }}>{k.label}</div>
<div className="text-lg font-mono font-bold" style={{ color: C.navy }}>{k.value}</div>
<div className="text-[10px]" style={{ color: C.blue }}>{k.sub}</div>
</div>
))}
</div>
);
}
export function CompanyProfile({ info }: { info: Record<string, any> }) {
const desc = info.longBusinessSummary || info.description || "";
if (!desc) return null;
const cur: string = info.currency || "USD";
const stats = [
{ l: "Employees", v: info.fullTimeEmployees ? Number(info.fullTimeEmployees).toLocaleString() : "—" },
{ l: "Country", v: info.country || "—" },
{ l: "Exchange", v: info.exchange || "—" },
{ l: "52W High", v: fmtPrice(info.fiftyTwoWeekHigh, cur) },
{ l: "52W Low", v: fmtPrice(info.fiftyTwoWeekLow, cur) },
{ l: "Beta", v: info.beta ? Number(info.beta).toFixed(2) : "—" },
{ l: "Avg Volume", v: info.averageVolume ? `${(info.averageVolume / 1e6).toFixed(1)}M` : "—" },
{ l: "Dividend Yield", v: info.dividendYield ? `${(info.dividendYield * 100).toFixed(2)}%` : "—" },
];
return (
<div className="report-section">
<h2 className="section-title">Company Profile</h2>
<p className="text-xs leading-relaxed mb-4" style={{ color: C.text }}>{desc.length > 800 ? desc.slice(0, 800) + "..." : desc}</p>
<div className="grid grid-cols-4 gap-x-6 gap-y-2">
{stats.map((s) => (
<div key={s.l} className="flex justify-between text-xs border-b py-1" style={{ borderColor: C.lightGray }}>
<span style={{ color: C.muted }}>{s.l}</span>
<span className="font-mono" style={{ color: C.navy }}>{s.v}</span>
</div>
))}
</div>
</div>
);
}
export function ExecutiveSummaryBlock({ institutional }: { institutional: InstitutionalData | null }) {
if (!institutional?.sections?.executive_summary) return null;
return (
<div className="report-section" style={{ background: "#F4F6F9", borderLeft: `4px solid ${C.gold}`, padding: "16px 20px" }}>
<div className="text-sm leading-relaxed" style={{ color: C.text }}
dangerouslySetInnerHTML={{ __html: renderMarkdown(institutional.sections.executive_summary) }} />
</div>
);
}
export function Disclaimer() {
return (
<div className="report-page flex flex-col justify-end">
<div className="w-full h-px mb-6" style={{ background: C.lightGray }} />
<h2 className="text-sm font-serif font-bold mb-3" style={{ color: C.navy }}>Disclaimer</h2>
<p className="text-[10px] leading-relaxed" style={{ color: C.muted }}>
This report was generated by ATLAS Terminal&apos;s AI analysis engine using Gemini AI and pre-computed
quantitative data from public sources (SEC EDGAR, Yahoo Finance). This is NOT investment advice.
All financial data is sourced from public filings and market data providers and may contain
errors or be outdated. The AI-generated perspectives are simulated institutional viewpoints
and do not represent the actual views of any named financial institution. Past performance
does not guarantee future results. Always consult a qualified financial advisor before making
investment decisions.
</p>
<div className="mt-8 text-center">
<div className="text-xs tracking-[0.2em] uppercase" style={{ color: C.gold }}>ATLAS TERMINAL</div>
<div className="text-[10px] mt-1" style={{ color: C.muted }}>Advanced Terminal for Liquid Asset Surveillance</div>
<div className="text-[10px] mt-1" style={{ color: C.muted }}>Generated {new Date().toISOString().slice(0, 16).replace("T", " ")} UTC</div>
</div>
</div>
);
}
@@ -0,0 +1,19 @@
/**
* Morgan Stanley Blue design tokens used by the institutional report.
*
* NOTE: P1-8 will hoist these into `tailwind.config.ts` so the rest of the app
* shares the same palette. Until then, both this constant and the global
* tailwind tokens (Terminal Noir) coexist intentionally.
*/
export const C = {
navy: "#1B2A4A",
blue: "#2E5B9A",
gold: "#C4A35A",
green: "#2D8B5E",
red: "#C0392B",
gray: "#6B7B8D",
lightGray: "#E8ECF0",
bg: "#FFFFFF",
text: "#1A1A2E",
muted: "#6B7B8D",
} as const;
@@ -0,0 +1,77 @@
/**
* Formatting + lookup helpers shared across every report section.
*
* `getValue` implements the multi-key column lookup pattern documented in
* `claude.md` §2.5 yfinance and yahooquery use different field names for the
* same metric, so callers pass a pipe-separated list of candidates.
*/
/** Map ISO currency codes → display symbol and decimal places. */
const CURRENCY_MAP: Record<string, { sym: string; decimals: number }> = {
USD: { sym: "$", decimals: 2 },
KRW: { sym: "₩", decimals: 0 },
JPY: { sym: "¥", decimals: 0 },
CNY: { sym: "¥", decimals: 2 },
HKD: { sym: "HK$", decimals: 2 },
EUR: { sym: "€", decimals: 2 },
GBP: { sym: "£", decimals: 2 },
INR: { sym: "₹", decimals: 0 },
TWD: { sym: "NT$", decimals: 0 },
SGD: { sym: "S$", decimals: 2 },
CAD: { sym: "C$", decimals: 2 },
AUD: { sym: "A$", decimals: 2 },
};
export function currencySymbol(currency?: string | null): string {
if (!currency) return "$";
return CURRENCY_MAP[currency.toUpperCase()]?.sym ?? currency;
}
export function fmtB(v: number | null | undefined, currency?: string | null): string {
if (v == null || isNaN(v)) return "N/A";
const s = currencySymbol(currency);
if (Math.abs(v) >= 1e12) return `${s}${(v / 1e12).toFixed(1)}T`;
if (Math.abs(v) >= 1e9) return `${s}${(v / 1e9).toFixed(1)}B`;
if (Math.abs(v) >= 1e6) return `${s}${(v / 1e6).toFixed(0)}M`;
return `${s}${v.toFixed(0)}`;
}
export function fmtPct(v: number | null | undefined): string {
if (v == null || isNaN(v)) return "N/A";
return `${v >= 0 ? "+" : ""}${v.toFixed(1)}%`;
}
export function fmtPrice(v: number | null | undefined, currency?: string | null): string {
if (v == null || isNaN(v)) return "N/A";
const info = CURRENCY_MAP[(currency ?? "USD").toUpperCase()] ?? { sym: currencySymbol(currency), decimals: 2 };
return `${info.sym}${v.toLocaleString("en-US", { minimumFractionDigits: info.decimals, maximumFractionDigits: info.decimals })}`;
}
export function getValue(data: Record<string, unknown>, key: string): number | null {
for (const k of key.split("|")) {
const v = data[k.trim()];
if (v != null && typeof v === "number" && !isNaN(v)) return v;
}
return null;
}
export async function fetchJson<T = unknown>(url: string, opts?: RequestInit): Promise<T | null> {
try {
const r = await fetch(url, opts);
return r.ok ? ((await r.json()) as T) : null;
} catch {
return null;
}
}
/**
* Tiny markdownHTML used by AI section bodies. Intentionally minimal we
* trust Gemini's structured output and avoid pulling in a full markdown lib.
*/
export function renderMarkdown(text: string): string {
return text
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
.replace(/\n- /g, "<br/>&bull; ")
.replace(/\n\* /g, "<br/>&bull; ")
.replace(/\n/g, "<br/>");
}
@@ -0,0 +1,126 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { PeerData, RelativeValData, ValuationTier } from "../types";
/**
* Pick the appropriate valuation framework given the company's profitability
* profile. Mirrors the 4-tier policy described in the report design notes:
* 1. DCF positive FCF
* 2. EV/EBITDA negative FCF but positive EBITDA (capex-heavy)
* 3. P/S both negative but revenue is growing >10% YoY
* 4. P/B (NAV) fallback for asset-heavy or distressed names
*/
export function detectValuationTier(
fcf: number | null,
ebitda: number | null,
revenueGrowth: number | null,
): ValuationTier {
if (fcf != null && fcf > 0) return "dcf";
if (ebitda != null && ebitda > 0) return "ev_ebitda";
if (revenueGrowth != null && revenueGrowth > 0.10) return "ps_revenue";
return "pb_nav";
}
interface DcfInputsLite {
fcf: number;
total_debt: number;
cash: number;
shares: number;
}
export function buildRelativeVal(
tier: ValuationTier,
peers: PeerData | null,
info: Record<string, any>,
di: DcfInputsLite | null,
hi: any,
): RelativeValData | null {
if (!di || !di.shares || di.shares <= 0) return null;
const netDebt = (di.total_debt || 0) - (di.cash || 0);
const revenue = hi?.revenue || info.totalRevenue || 0;
const ebitda = hi?.ebitda || 0;
const fcf = hi?.free_cash_flow ?? info.freeCashflow ?? di.fcf ?? 0;
const revGrowth = hi?.revenue_growth ?? info.revenueGrowth ?? null;
const profitMargin = hi?.profit_margin ?? info.profitMargins ?? 0;
const rule40 = revGrowth != null ? revGrowth * 100 + profitMargin * 100 : null;
const cash = di.cash || 0;
const qBurn = fcf < 0 ? Math.abs(fcf) / 4 : 0;
const cashRunway = qBurn > 0 ? cash / qBurn : null;
if (tier === "ev_ebitda") {
const peerAvg = peers?.averages?.ev_ebitda ?? 12;
const impliedEV = peerAvg * ebitda;
const baseVal = (impliedEV - netDebt) / di.shares;
return {
tier,
tierLabel: "EV/EBITDA Relative Valuation",
tierReason:
"Free cash flow is negative due to heavy capital investment, but EBITDA is positive — the company generates operating profit before reinvestment.",
method: "EV/EBITDA",
multipleName: "EV/EBITDA",
peerAvgMultiple: peerAvg,
companyMetric: ebitda,
metricLabel: "EBITDA",
bear: { multiple: peerAvg * 0.7, value: (peerAvg * 0.7 * ebitda - netDebt) / di.shares },
base: { multiple: peerAvg, value: baseVal },
bull: { multiple: peerAvg * 1.3, value: (peerAvg * 1.3 * ebitda - netDebt) / di.shares },
netDebt,
shares: di.shares,
cashRunwayQuarters: cashRunway,
revenueGrowth: revGrowth,
rule40,
ebitda,
fcf,
};
}
if (tier === "ps_revenue") {
const peerAvg = peers?.averages?.ps ?? 4;
const impliedMC = peerAvg * revenue;
const baseVal = impliedMC / di.shares;
return {
tier,
tierLabel: "Price/Sales Relative Valuation",
tierReason:
"Both FCF and EBITDA are negative, but revenue is growing rapidly. P/S (Price-to-Sales) multiple is the appropriate valuation framework for high-growth, pre-profit companies.",
method: "P/S",
multipleName: "P/S",
peerAvgMultiple: peerAvg,
companyMetric: revenue,
metricLabel: "Revenue",
bear: { multiple: peerAvg * 0.6, value: (peerAvg * 0.6 * revenue) / di.shares },
base: { multiple: peerAvg, value: baseVal },
bull: { multiple: peerAvg * 1.5, value: (peerAvg * 1.5 * revenue) / di.shares },
netDebt,
shares: di.shares,
cashRunwayQuarters: cashRunway,
revenueGrowth: revGrowth,
rule40,
ebitda,
fcf,
};
}
// pb_nav
const bookVal = hi?.book_value || 0;
const peerAvg = peers?.averages?.pb ?? 2;
const baseVal = bookVal > 0 ? bookVal * peerAvg : 0;
return {
tier,
tierLabel: "Price/Book (NAV) Valuation",
tierReason:
"FCF, EBITDA, and revenue growth are all weak or negative. Asset-based valuation (P/B) provides the most relevant framework.",
method: "P/B",
multipleName: "P/B",
peerAvgMultiple: peerAvg,
companyMetric: bookVal * di.shares,
metricLabel: "Book Value",
bear: { multiple: peerAvg * 0.6, value: bookVal * peerAvg * 0.6 },
base: { multiple: peerAvg, value: baseVal },
bull: { multiple: peerAvg * 1.5, value: bookVal * peerAvg * 1.5 },
netDebt,
shares: di.shares,
cashRunwayQuarters: cashRunway,
revenueGrowth: revGrowth,
rule40,
ebitda,
fcf,
};
}
+109 -44
View File
@@ -1,20 +1,14 @@
"use client"; "use client";
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { BarChart3, Info } from "lucide-react";
import { import {
BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid,
Tooltip, ResponsiveContainer, Cell, Legend, ReferenceLine, Tooltip, ResponsiveContainer, Cell, Legend, ReferenceLine,
} from "recharts"; } from "recharts";
import { useTicker } from "../lib/use-ticker"; import { useTicker } from "../lib/use-ticker";
import { C } from "./design-tokens";
/*
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",
};
/* /*
Types Types
@@ -68,20 +62,31 @@ interface RelativeValData {
/* /*
Utility 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 (v == null || isNaN(v)) return "N/A";
if (Math.abs(v) >= 1e12) return `$${(v / 1e12).toFixed(1)}T`; const s = curSym(c);
if (Math.abs(v) >= 1e9) return `$${(v / 1e9).toFixed(1)}B`; if (Math.abs(v) >= 1e12) return `${s}${(v / 1e12).toFixed(1)}T`;
if (Math.abs(v) >= 1e6) return `$${(v / 1e6).toFixed(0)}M`; if (Math.abs(v) >= 1e9) return `${s}${(v / 1e9).toFixed(1)}B`;
return `$${v.toFixed(0)}`; if (Math.abs(v) >= 1e6) return `${s}${(v / 1e6).toFixed(0)}M`;
return `${s}${v.toFixed(0)}`;
} }
function fmtPct(v: number | null | undefined): string { function fmtPct(v: number | null | undefined): string {
if (v == null || isNaN(v)) return "N/A"; if (v == null || isNaN(v)) return "N/A";
return `${v >= 0 ? "+" : ""}${v.toFixed(1)}%`; 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"; 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 { 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; } 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) { 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/>"); 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 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 }) { 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 now = new Date();
const dateStr = now.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }); 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 price = info.currentPrice || info.regularMarketPrice || 0;
const target = consensus?.target_mean ?? info.targetMeanPrice ?? 0; const target = consensus?.target_mean ?? info.targetMeanPrice ?? 0;
const upside = price > 0 && target > 0 ? ((target - price) / price) * 100 : 0; const upside = price > 0 && target > 0 ? ((target - price) / price) * 100 : 0;
const rec = consensus?.recommendation?.toUpperCase() || "N/A"; 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 ( return (
<div className="report-page cover-page flex flex-col items-center justify-center text-center"> <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 */} {/* 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="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-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="w-px h-8" style={{ background: C.lightGray }} />
<div className="text-right"> <div className="text-right">
<div className="text-xs" style={{ color: C.muted }}>Target</div> <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>
<div className="text-right"> <div className="text-right">
<div className="text-xs" style={{ color: C.muted }}>Upside</div> <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 }}> <div className="grid grid-cols-4 gap-6 mb-8" style={{ maxWidth: 640 }}>
{[ {[
{ label: "Price", value: fmtPrice(price) }, { label: "Price", value: fmtPrice(price, cur) },
{ label: "Market Cap", value: fmtB(info.marketCap) }, { label: "Market Cap", value: fmtB(info.marketCap, cur) },
{ label: relativeVal ? `${relativeVal.method} Fair Value` : "DCF Fair Value", value: relativeVal ? fmtPrice(relativeVal.base.value) : dcf?.base != null ? fmtPrice(dcf.base) : "N/A" }, { 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" }, { label: "Analysts", value: consensus ? `${consensus.num_analysts}` : "N/A" },
].map((k) => ( ].map((k) => (
<div key={k.label}> <div key={k.label}>
@@ -191,10 +221,11 @@ function TableOfContents({ hasInstitutional }: { hasInstitutional: boolean }) {
} }
function KpiRow({ info }: { info: Record<string, any> }) { function KpiRow({ info }: { info: Record<string, any> }) {
const cur: string = info.currency || "USD";
const kpis = [ const kpis = [
{ label: "Revenue", value: fmtB(info.totalRevenue), sub: fmtPct((info.revenueGrowth || 0) * 100) }, { label: "Revenue", value: fmtB(info.totalRevenue, cur), sub: fmtPct((info.revenueGrowth || 0) * 100) },
{ label: "Net Income", value: fmtB(info.netIncomeToCommon), sub: `Margin ${((info.profitMargins || 0) * 100).toFixed(1)}%` }, { label: "Net Income", value: fmtB(info.netIncomeToCommon, cur), 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: "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: "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: "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"}` }, { 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> }) { function CompanyProfile({ info }: { info: Record<string, any> }) {
const desc = info.longBusinessSummary || info.description || ""; const desc = info.longBusinessSummary || info.description || "";
if (!desc) return null; if (!desc) return null;
const cur: string = info.currency || "USD";
const stats = [ const stats = [
{ l: "Employees", v: info.fullTimeEmployees?.toLocaleString() || "N/A" }, { l: "Employees", v: info.fullTimeEmployees ? Number(info.fullTimeEmployees).toLocaleString() : "" },
{ l: "Country", v: info.country || "N/A" }, { l: "Country", v: info.country || "" },
{ l: "Founded", v: info.companyOfficers?.[0]?.fiscalYear || "N/A" }, { l: "Exchange", v: info.exchange || "" },
{ l: "52W High", v: fmtPrice(info.fiftyTwoWeekHigh) }, { l: "52W High", v: fmtPrice(info.fiftyTwoWeekHigh, cur) },
{ l: "52W Low", v: fmtPrice(info.fiftyTwoWeekLow) }, { l: "52W Low", v: fmtPrice(info.fiftyTwoWeekLow, cur) },
{ l: "Beta", v: info.beta ? info.beta.toFixed(2) : "N/A" }, { 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: "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" }, { 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" /> <CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" />
<XAxis dataKey="year" tick={{ fontSize: 10 }} /> <XAxis dataKey="year" tick={{ fontSize: 10 }} />
<YAxis 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="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="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 }} /> <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 [loading, setLoading] = useState(false);
const [progress, setProgress] = useState(""); const [progress, setProgress] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [aiNotice, setAiNotice] = useState("");
const generateReport = useCallback(async () => { 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") || ""; const apiKey = localStorage.getItem("atlas_gemini_key") || "";
try { try {
@@ -1130,27 +1163,48 @@ export default function ReportPage() {
const mergedInfo: Record<string, any> = {}; const mergedInfo: Record<string, any> = {};
if (ov?.data) Object.assign(mergedInfo, { if (ov?.data) Object.assign(mergedInfo, {
longName: ov.data.name, sector: ov.data.sector, industry: ov.data.industry, 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, 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, beta: ov.data.beta, fiftyTwoWeekHigh: ov.data.high_52w, fiftyTwoWeekLow: ov.data.low_52w,
currentPrice: ov.data.price, longBusinessSummary: ov.data.description, 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, { if (sec) Object.assign(mergedInfo, {
sector: sec.sector || mergedInfo.sector, industry: sec.industry || mergedInfo.industry, sector: sec.sector || mergedInfo.sector, industry: sec.industry || mergedInfo.industry,
marketCap: sec.market_cap || mergedInfo.marketCap, trailingPE: sec.pe_ratio || mergedInfo.trailingPE, marketCap: sec.market_cap || mergedInfo.marketCap, trailingPE: sec.pe_ratio || mergedInfo.trailingPE,
forwardPE: sec.forward_pe, currentPrice: sec.current_price || mergedInfo.currentPrice, forwardPE: sec.forward_pe || mergedInfo.forwardPE,
targetMeanPrice: sec.target_mean_price, fiftyTwoWeekHigh: sec.fifty_two_week_high || mergedInfo.fiftyTwoWeekHigh, 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, fiftyTwoWeekLow: sec.fifty_two_week_low || mergedInfo.fiftyTwoWeekLow,
fullTimeEmployees: sec.employees, exchange: sec.exchange, fullTimeEmployees: sec.employees || mergedInfo.fullTimeEmployees,
debtToEquity: sec.debt_to_equity, exchange: sec.exchange || mergedInfo.exchange,
debtToEquity: sec.debt_to_equity || mergedInfo.debtToEquity,
currency: sec.currency || mergedInfo.currency || "USD",
}); });
if (hi) Object.assign(mergedInfo, { if (hi) Object.assign(mergedInfo, {
totalRevenue: hi.revenue, profitMargins: hi.profit_margin, totalRevenue: hi.revenue, profitMargins: hi.profit_margin ?? mergedInfo.profitMargins,
returnOnEquity: hi.roe, returnOnAssets: hi.roa, freeCashflow: hi.free_cash_flow, 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, netIncomeToCommon: hi.revenue && hi.profit_margin ? hi.revenue * hi.profit_margin : undefined,
enterpriseToEbitda: hi.ebitda && hi.revenue ? undefined : undefined, revenueGrowth: hi.revenue_growth ?? mergedInfo.revenueGrowth,
revenueGrowth: hi.revenue_growth, debtToEquity: hi.debt_to_equity ?? mergedInfo.debtToEquity, debtToEquity: hi.debt_to_equity ?? mergedInfo.debtToEquity,
operatingMargins: hi.operating_margin, grossMargins: hi.gross_margin, operatingMargins: hi.operating_margin, grossMargins: hi.gross_margin,
currency: hi.currency || mergedInfo.currency || "USD",
}); });
if (Object.keys(mergedInfo).length > 0) setInfo(mergedInfo); if (Object.keys(mergedInfo).length > 0) setInfo(mergedInfo);
@@ -1226,7 +1280,7 @@ export default function ReportPage() {
/* ── Phase 4: Gemini AI ── */ /* ── Phase 4: Gemini AI ── */
if (!apiKey) { 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 { } else {
setProgress("Phase 4/4 — Generating Wall Street 10 analysis (Gemini AI, ~15s)..."); setProgress("Phase 4/4 — Generating Wall Street 10 analysis (Gemini AI, ~15s)...");
const instRes = await fetch("/api/analysis/institutional", { const instRes = await fetch("/api/analysis/institutional", {
@@ -1234,7 +1288,10 @@ export default function ReportPage() {
body: JSON.stringify({ ticker, api_key: apiKey }), body: JSON.stringify({ ticker, api_key: apiKey }),
}); });
if (instRes.ok) { setInstitutional(await instRes.json()); } 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(""); setProgress("");
@@ -1274,6 +1331,12 @@ export default function ReportPage() {
</div> </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 && <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 && ( {loading && (
<div className="bg-bg-card border border-border rounded-lg p-8 text-center"> <div className="bg-bg-card border border-border rounded-lg p-8 text-center">
@@ -1286,7 +1349,9 @@ export default function ReportPage() {
{!hasData && !loading && ( {!hasData && !loading && (
<div className="bg-bg-card border border-border rounded-lg p-8 text-center"> <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> <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"> <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), 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"; "use client";
import { useEffect, useState } from "react";
import { ResearchGridLayout } from "../components/research/ResearchGridLayout"; import { ResearchGridLayout } from "../components/research/ResearchGridLayout";
import type { ResearchDashboardPayload } from "../components/research/types"; 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"; import { useTicker } from "../lib/use-ticker";
interface OverviewResp {
asset_type?: string;
}
export default function ResearchPage() { export default function ResearchPage() {
const { ticker, initialized } = useTicker(); 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(() => { const overviewUrl = initialized ? `/api/market/overview/${ticker}` : null;
if (!initialized) return; const dashUrl = initialized ? `/api/research/dashboard/${encodeURIComponent(ticker)}` : null;
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]);
if (loading) { const overview = useApi<OverviewResp>(overviewUrl);
return ( const dashboard = useApi<ResearchDashboardPayload>(dashUrl);
<div className="flex items-center justify-center h-64">
<div className="text-accent-green animate-pulse font-mono">Loading research</div> const assetType = overview.data?.asset_type || "equity";
</div> const loading = overview.loading || dashboard.loading;
);
if (!initialized || loading) {
return <LoadingPulse label="Loading research…" />;
} }
return ( return (
<div> <div className="atlas-page">
<h1 className="text-2xl font-bold mb-6"> <SectionHeading level={1}>{ticker} Research</SectionHeading>
<span className="text-accent-green">{ticker}</span> Research
</h1>
{assetType === "equity" ? ( {assetType === "equity" ? (
loadError ? ( dashboard.error ? (
<div className="bg-bg-card border border-border rounded-lg p-5 text-accent-red text-sm">{loadError}</div> <ErrorBanner
) : dashboard ? ( variant="info"
message={`${dashboard.error} Some quant widgets may be unavailable — try refreshing or checking the backend.`}
/>
) : dashboard.data ? (
<> <>
{dashboard.error && ( {dashboard.data.error && (
<div className="mb-4 rounded-lg border border-accent-yellow/40 bg-bg-card px-4 py-3 text-sm text-accent-yellow"> <ErrorBanner
{dashboard.error} Some widgets may be empty. className="mb-4"
</div> 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. No dashboard data available. Check the API response or try a different ticker.
</div> </div>
) )
) : assetType === "etf" ? ( ) : assetType === "etf" ? (
<div className="bg-bg-card border border-border rounded-lg p-5 mb-6"> <div className="atlas-card p-5 mb-6">
<h3 className="text-text-secondary text-sm font-semibold mb-3">ETF Research</h3> <h3 className="text-sm font-semibold uppercase tracking-[0.12em] text-text-secondary">ETF Research</h3>
<div className="text-text-secondary text-sm"> <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. Provides Holdings Analysis, Sector Breakdown, and Overlap Analysis. Piotroski F-Score and corporate financial dashboards are not applicable to ETFs.
</div> </div>
</div> </div>
) : ( ) : (
<div className="bg-bg-card border border-border rounded-lg p-5 mb-6"> <div className="atlas-card p-5 mb-6">
<h3 className="text-text-secondary text-sm font-semibold mb-3">Commodity Research</h3> <h3 className="text-sm font-semibold uppercase tracking-[0.12em] text-text-secondary">Commodity Research</h3>
<div className="text-text-secondary text-sm"> <div className="text-text-secondary text-sm">
Focuses on Seasonal Analysis and Supply/Demand factors. Equity-specific indicators are not displayed. Focuses on Seasonal Analysis and Supply/Demand factors. Equity-specific indicators are not displayed.
</div> </div>
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { chartPalette, lightweightTheme } from "../lib/chart-theme";
interface ScreenerRow { interface ScreenerRow {
ticker: string; ticker: string;
@@ -156,19 +157,17 @@ export default function ScreenerPage() {
chart = createChart(el, { chart = createChart(el, {
width: el.clientWidth, width: el.clientWidth,
height: 320, height: 320,
layout: { background: { color: "#1A1A26" }, textColor: "#9CA3AF" }, ...lightweightTheme,
grid: { vertLines: { color: "#2A2A3A" }, horzLines: { color: "#2A2A3A" } },
crosshair: { mode: 0 }, 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( strat.setData(
btResult.dates!.map((d, i) => ({ btResult.dates!.map((d, i) => ({
time: d as string & { __brand?: "Time" }, time: d as string & { __brand?: "Time" },
value: btResult.equity_curve![i], value: btResult.equity_curve![i],
})) }))
); );
const bench = chart.addLineSeries({ color: "#4DA6FF", lineWidth: 2 }); const bench = chart.addLineSeries({ color: chartPalette.blue, lineWidth: 2 });
bench.setData( bench.setData(
btResult.dates!.map((d, i) => ({ btResult.dates!.map((d, i) => ({
time: d as string & { __brand?: "Time" }, time: d as string & { __brand?: "Time" },
@@ -203,19 +202,17 @@ export default function ScreenerPage() {
chart = createChart(el, { chart = createChart(el, {
width: el.clientWidth, width: el.clientWidth,
height: 320, height: 320,
layout: { background: { color: "#1A1A26" }, textColor: "#9CA3AF" }, ...lightweightTheme,
grid: { vertLines: { color: "#2A2A3A" }, horzLines: { color: "#2A2A3A" } },
crosshair: { mode: 0 }, 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( port.setData(
ptResult.dates!.map((d, i) => ({ ptResult.dates!.map((d, i) => ({
time: d as string & { __brand?: "Time" }, time: d as string & { __brand?: "Time" },
value: ptResult.equity_curve![i], value: ptResult.equity_curve![i],
})) }))
); );
const bench = chart.addLineSeries({ color: "#4DA6FF", lineWidth: 2 }); const bench = chart.addLineSeries({ color: chartPalette.blue, lineWidth: 2 });
bench.setData( bench.setData(
ptResult.dates!.map((d, i) => ({ ptResult.dates!.map((d, i) => ({
time: d as string & { __brand?: "Time" }, time: d as string & { __brand?: "Time" },
@@ -1,5 +1,8 @@
"use client"; "use client";
import { useState, useEffect } from "react"; 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 = [ const KEYS = [
{ id: "atlas_gemini_key", label: "Gemini API Key", placeholder: "AIza..." }, { id: "atlas_gemini_key", label: "Gemini API Key", placeholder: "AIza..." },
@@ -36,36 +39,38 @@ export default function SettingsPage() {
} }
return ( return (
<div className="max-w-2xl"> <div className="atlas-page max-w-3xl">
<h1 className="text-2xl font-bold mb-6">Settings</h1> <SectionHeading level={1}>Settings</SectionHeading>
{/* Backend Status */} <Card title="System Status" subtitle="Local keys and backend health for your desk.">
<div className="bg-bg-card border border-border rounded-lg p-4 mb-6">
<h3 className="text-text-secondary text-sm font-semibold mb-3">System Status</h3>
<div className="flex items-center gap-3"> <div className="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"> <span className="text-text-primary text-sm">
Backend API: {backendStatus === "ok" ? "Connected" : backendStatus === "error" ? "Disconnected" : "Checking..."} Backend API: {backendStatus === "ok" ? "Connected" : backendStatus === "error" ? "Disconnected" : "Checking..."}
</span> </span>
</div> </div>
</div> </Card>
{/* API Keys */} <Card title="API Keys" subtitle="Stored locally in this browser session.">
<div className="bg-bg-card border border-border rounded-lg p-5 mb-6">
<h3 className="text-text-secondary text-sm font-semibold mb-4">API Keys</h3>
<div className="space-y-4"> <div className="space-y-4">
{KEYS.map((k) => ( {KEYS.map((k) => (
<div key={k.id}> <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"> <div className="flex items-center gap-3">
<input <input
type="password" type="password"
value={values[k.id] || ""} value={values[k.id] || ""}
onChange={(e) => setValues({ ...values, [k.id]: e.target.value })} onChange={(e) => setValues({ ...values, [k.id]: e.target.value })}
placeholder={k.placeholder} 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>
</div> </div>
))} ))}
@@ -73,20 +78,18 @@ export default function SettingsPage() {
<button <button
onClick={handleSave} 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"} {saved ? "Saved!" : "Save Keys"}
</button> </button>
</div> </Card>
{/* Info */} <Card title="About">
<div className="bg-bg-card border border-border rounded-lg p-4">
<h3 className="text-text-secondary text-sm font-semibold mb-2">About</h3>
<div className="text-text-muted text-sm space-y-1"> <div className="text-text-muted text-sm space-y-1">
<p>ATLAS Terminal v2.0 Advanced Trading & Liquidity Analysis System</p> <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> <p>API keys are stored locally in your browser. They are never sent to our servers.</p>
</div> </div>
</div> </Card>
</div> </div>
); );
} }
@@ -1,5 +1,10 @@
"use client"; "use client";
import { useEffect, useState, useRef } from "react"; 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"; import { useTicker } from "../lib/use-ticker";
interface Indicators { interface Indicators {
@@ -67,10 +72,8 @@ export default function TechnicalPage() {
chart = lc.createChart(chartRef.current!, { chart = lc.createChart(chartRef.current!, {
width: chartRef.current!.clientWidth, width: chartRef.current!.clientWidth,
height: 400, height: 400,
layout: { background: { color: "#1A1A26" }, textColor: "#9CA3AF" }, ...lightweightTheme,
grid: { vertLines: { color: "#2A2A3A" }, horzLines: { color: "#2A2A3A" } },
crosshair: { mode: 0 }, crosshair: { mode: 0 },
timeScale: { borderColor: "#2A2A3A" },
}); });
// v5 API: use addSeries with series type constructor // v5 API: use addSeries with series type constructor
@@ -80,12 +83,12 @@ export default function TechnicalPage() {
if (CandlestickSeries && typeof chart.addSeries === "function") { if (CandlestickSeries && typeof chart.addSeries === "function") {
// v5 path // v5 path
const candlestickSeries = chart.addSeries(CandlestickSeries, { const candlestickSeries = chart.addSeries(CandlestickSeries, {
upColor: "#00D4AA", upColor: chartPalette.green,
downColor: "#FF4757", downColor: chartPalette.red,
borderUpColor: "#00D4AA", borderUpColor: chartPalette.green,
borderDownColor: "#FF4757", borderDownColor: chartPalette.red,
wickUpColor: "#00D4AA", wickUpColor: chartPalette.green,
wickDownColor: "#FF4757", wickDownColor: chartPalette.red,
}); });
candlestickSeries.setData(bars); candlestickSeries.setData(bars);
@@ -100,18 +103,18 @@ export default function TechnicalPage() {
bars.map((b: ChartBar) => ({ bars.map((b: ChartBar) => ({
time: b.time, time: b.time,
value: b.volume, 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") { } else if (typeof chart.addCandlestickSeries === "function") {
// v4 fallback // v4 fallback
const candlestickSeries = chart.addCandlestickSeries({ const candlestickSeries = chart.addCandlestickSeries({
upColor: "#00D4AA", upColor: chartPalette.green,
downColor: "#FF4757", downColor: chartPalette.red,
borderUpColor: "#00D4AA", borderUpColor: chartPalette.green,
borderDownColor: "#FF4757", borderDownColor: chartPalette.red,
wickUpColor: "#00D4AA", wickUpColor: chartPalette.green,
wickDownColor: "#FF4757", wickDownColor: chartPalette.red,
}); });
candlestickSeries.setData(bars); candlestickSeries.setData(bars);
@@ -126,7 +129,7 @@ export default function TechnicalPage() {
bars.map((b: ChartBar) => ({ bars.map((b: ChartBar) => ({
time: b.time, time: b.time,
value: b.volume, 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]); }, [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>; if (loading) return <LoadingPulse label="Loading technical data…" />;
const rsiColor = indicators?.rsi_14
? indicators.rsi_14 > 70 ? "text-accent-red" : indicators.rsi_14 < 30 ? "text-accent-green" : "text-text-primary"
: "text-text-primary";
const macdSignal = indicators?.macd const macdSignal = indicators?.macd
? indicators.macd.histogram > 0 ? "Bullish" : "Bearish" ? indicators.macd.histogram > 0 ? "Bullish" : "Bearish"
: "—"; : "—";
return ( return (
<div> <div className="atlas-page">
<h1 className="text-2xl font-bold mb-6"> <SectionHeading level={1}>{ticker} Technical Analysis</SectionHeading>
<span className="text-accent-green">{ticker}</span> Technical Analysis
</h1>
{/* Period Selector */} {/* Period Selector */}
<div className="flex gap-2 mb-4"> <div className="flex gap-2 mb-4">
@@ -169,10 +166,10 @@ export default function TechnicalPage() {
<button <button
key={p} key={p}
onClick={() => setPeriod(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 period === p
? "bg-accent-green text-bg-primary font-semibold" ? "bg-brand-navy text-white font-semibold"
: "bg-bg-card text-text-secondary hover:bg-bg-card/80" : "bg-surface-raised text-text-secondary shadow-card hover:bg-surface-sunken"
}`} }`}
> >
{p.toUpperCase()} {p.toUpperCase()}
@@ -181,43 +178,34 @@ export default function TechnicalPage() {
</div> </div>
{/* Chart */} {/* 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 ref={chartRef} className="w-full" style={{ minHeight: 400 }} />
</div> </ChartContainer>
{/* Indicator Cards */} {/* Indicator Cards */}
{indicators && ( {indicators && (
<div className="grid grid-cols-4 gap-3 mb-6"> <div className="grid grid-cols-4 gap-3 mb-6">
<div className="bg-bg-card border border-border rounded-lg p-4"> <StatCard label="Current Price" value={`$${indicators.current_price?.toFixed(2)}`} />
<div className="text-text-muted text-xs mb-1">Current Price</div> <StatCard
<div className="text-text-primary font-mono font-bold text-xl">${indicators.current_price?.toFixed(2)}</div> label="RSI (14)"
</div> value={indicators.rsi_14}
<div className="bg-bg-card border border-border rounded-lg p-4"> tone={indicators.rsi_14 > 70 ? "negative" : indicators.rsi_14 < 30 ? "positive" : "default"}
<div className="text-text-muted text-xs mb-1">RSI (14)</div> detail={indicators.rsi_14 > 70 ? "Overbought" : indicators.rsi_14 < 30 ? "Oversold" : "Neutral"}
<div className={`font-mono font-bold text-xl ${rsiColor}`}>{indicators.rsi_14}</div> />
<div className="text-text-muted text-xs mt-1"> <StatCard
{indicators.rsi_14 > 70 ? "Overbought" : indicators.rsi_14 < 30 ? "Oversold" : "Neutral"} label="MACD Signal"
</div> value={macdSignal}
</div> tone={indicators.macd.histogram > 0 ? "positive" : "negative"}
<div className="bg-bg-card border border-border rounded-lg p-4"> detail={`H: ${indicators.macd.histogram.toFixed(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"}`}> <StatCard label="ATR (14)" value={indicators.atr_14} detail="Volatility" />
{macdSignal}
</div>
<div className="text-text-muted text-xs mt-1 font-mono">H: {indicators.macd.histogram.toFixed(4)}</div>
</div>
<div className="bg-bg-card border border-border rounded-lg p-4">
<div className="text-text-muted text-xs mb-1">ATR (14)</div>
<div className="text-text-primary font-mono font-bold text-xl">{indicators.atr_14}</div>
<div className="text-text-muted text-xs mt-1">Volatility</div>
</div>
</div> </div>
)} )}
{/* Moving Averages & Bollinger */} {/* Moving Averages & Bollinger */}
{indicators && ( {indicators && (
<div className="grid grid-cols-2 gap-4 mb-6"> <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> <h3 className="text-text-secondary text-sm font-semibold mb-3">Moving Averages</h3>
<div className="space-y-2"> <div className="space-y-2">
{[ {[
@@ -232,8 +220,8 @@ export default function TechnicalPage() {
<div className="flex items-center gap-3"> <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-text-primary font-mono">{ma.value != null ? `$${ma.value.toFixed(2)}` : "—"}</span>
<span className={`text-xs font-semibold px-2 py-0.5 rounded ${ <span className={`text-xs font-semibold px-2 py-0.5 rounded ${
ma.signal === true ? "bg-accent-green/20 text-accent-green" : ma.signal === true ? "bg-fin-positive/15 text-fin-positive" :
ma.signal === false ? "bg-accent-red/20 text-accent-red" : "bg-bg-primary text-text-muted" 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"} {ma.signal === true ? "ABOVE" : ma.signal === false ? "BELOW" : "N/A"}
</span> </span>
@@ -241,22 +229,22 @@ export default function TechnicalPage() {
</div> </div>
))} ))}
</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> <h3 className="text-text-secondary text-sm font-semibold mb-3">Bollinger Bands (20, 2)</h3>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-text-muted">Upper Band</span> <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>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-text-muted">Middle Band</span> <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>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-text-muted">Lower Band</span> <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>
<div className="flex justify-between text-sm border-t border-border pt-3"> <div className="flex justify-between text-sm border-t border-border pt-3">
<span className="text-text-muted">BB Width</span> <span className="text-text-muted">BB Width</span>
@@ -285,26 +273,26 @@ export default function TechnicalPage() {
</div> </div>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-text-muted">Histogram</span> <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)} {indicators.macd.histogram.toFixed(4)}
</span> </span>
</div> </div>
</div> </div>
</div> </ChartContainer>
</div> </div>
)} )}
{/* Fibonacci Levels */} {/* Fibonacci Levels */}
{fib && ( {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> <h3 className="text-text-secondary text-sm font-semibold mb-3">Fibonacci Retracement</h3>
<div className="grid grid-cols-7 gap-3"> <div className="grid grid-cols-7 gap-3">
{Object.entries(fib.levels).map(([level, price]) => { {Object.entries(fib.levels).map(([level, price]) => {
const isNear = Math.abs(price - fib.current_price) / fib.current_price < 0.02; const isNear = Math.abs(price - fib.current_price) / fib.current_price < 0.02;
return ( 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="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)} ${price.toFixed(2)}
</div> </div>
</div> </div>
@@ -314,7 +302,7 @@ export default function TechnicalPage() {
<div className="mt-3 text-text-muted text-xs font-mono"> <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)} 52W Range: ${fib.low_52w.toFixed(2)} ${fib.high_52w.toFixed(2)} | Current: ${fib.current_price.toFixed(2)}
</div> </div>
</div> </ChartContainer>
)} )}
</div> </div>
); );
@@ -1,7 +1,20 @@
"use client"; "use client";
import { useEffect, useState } from "react"; 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"; import { useTicker } from "../lib/use-ticker";
interface SmartDefaults {
wacc?: number;
terminal_growth?: number;
fcf_growth?: number;
}
interface OverviewResp {
asset_type?: string;
}
interface DCFInputs { interface DCFInputs {
fcf?: number; fcf?: number;
total_debt?: number; total_debt?: number;
@@ -51,47 +64,59 @@ type ValuationTab = "dcf" | "sensitivity" | "montecarlo" | "tornado" | "reverse"
export default function ValuationPage() { export default function ValuationPage() {
const { ticker, initialized } = useTicker(); 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 [wacc, setWacc] = useState(10);
const [terminalGrowth, setTerminalGrowth] = useState(2.5); const [terminalGrowth, setTerminalGrowth] = useState(2.5);
const [fcfGrowth, setFcfGrowth] = useState(8); const [fcfGrowth, setFcfGrowth] = useState(8);
const [loading, setLoading] = useState(true);
const [dcfLoading, setDcfLoading] = useState(false); const [dcfLoading, setDcfLoading] = useState(false);
const [activeTab, setActiveTab] = useState<ValuationTab>("dcf"); 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 [sensitivity, setSensitivity] = useState<SensitivityData | null>(null);
const [tornado, setTornado] = useState<TornadoItem[]>([]); const [tornado, setTornado] = useState<TornadoItem[]>([]);
const [monteCarlo, setMonteCarlo] = useState<MonteCarloData | null>(null); const [monteCarlo, setMonteCarlo] = useState<MonteCarloData | null>(null);
const [reverseDCF, setReverseDCF] = useState<{ implied_growth: number | null; current_price: number | null } | null>(null); const [reverseDCF, setReverseDCF] = useState<{ implied_growth: number | null; current_price: number | null } | null>(null);
const [advLoading, setAdvLoading] = useState(false); 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(() => { useEffect(() => {
if (!initialized) return;
setLoading(true);
setDcfResult(null); setDcfResult(null);
setSensitivity(null); setSensitivity(null);
setTornado([]); setTornado([]);
setMonteCarlo(null); setMonteCarlo(null);
setReverseDCF(null); setReverseDCF(null);
Promise.all([ setRuntimeNotice("");
fetch(`/api/valuation/dcf-inputs/${ticker}`).then((r) => r.ok ? r.json() : null), }, [ticker]);
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), // Derived notice: prioritise runtime errors (DCF failures), then load-time warnings.
fetch(`/api/market/overview/${ticker}`).then((r) => r.ok ? r.json() : null), const loadNotice =
]).then(([i, c, d, o]) => { runtimeNotice ||
setInputs(i); (inputsApi.error
setConsensus(c); ? "Could not load valuation inputs. Check the backend connection and retry."
setAssetType(o?.asset_type || "equity"); : !loading && (!inputs?.fcf || !inputs?.shares)
if (d?.wacc) setWacc(d.wacc); ? `DCF inputs are incomplete for ${ticker}. Smart defaults applied — review values before running models.`
if (d?.terminal_growth) setTerminalGrowth(d.terminal_growth); : "");
if (d?.fcf_growth) setFcfGrowth(d.fcf_growth);
setLoading(false);
}).catch(() => setLoading(false));
}, [ticker, initialized]);
async function runDCF() { async function runDCF() {
if (!inputs) return; if (!inputs) return;
@@ -111,8 +136,15 @@ export default function ValuationPage() {
fcf_growth_rate: fcfGrowth / 100, fcf_growth_rate: fcfGrowth / 100,
}), }),
}); });
if (res.ok) setDcfResult(await res.json()); if (res.ok) {
} catch { /* */ } 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); setDcfLoading(false);
} }
@@ -167,19 +199,20 @@ export default function ValuationPage() {
}); });
if (res.ok) setReverseDCF(await res.json()); if (res.ok) setReverseDCF(await res.json());
} }
} catch { /* */ } setRuntimeNotice("");
} catch {
setRuntimeNotice(`${tab} model request failed. Check the backend and retry.`);
}
setAdvLoading(false); 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") { if (assetType !== "equity") {
return ( return (
<div> <div className="atlas-page">
<h1 className="text-2xl font-bold mb-6"> <SectionHeading level={1}>{ticker} Valuation</SectionHeading>
<span className="text-accent-green">{ticker}</span> Valuation <div className="atlas-card p-5">
</h1>
<div className="bg-bg-card border border-border rounded-lg p-5">
<h3 className="text-text-secondary text-sm font-semibold mb-3"> <h3 className="text-text-secondary text-sm font-semibold mb-3">
{assetType === "etf" ? "ETF Valuation Mode" : "Commodity Valuation Mode"} {assetType === "etf" ? "ETF Valuation Mode" : "Commodity Valuation Mode"}
</h3> </h3>
@@ -194,10 +227,10 @@ export default function ValuationPage() {
} }
return ( return (
<div> <div className="atlas-page">
<h1 className="text-2xl font-bold mb-6"> <SectionHeading level={1}>{ticker} Valuation</SectionHeading>
<span className="text-accent-green">{ticker}</span> Valuation
</h1> <ErrorBanner className="mb-4" variant="info" message={loadNotice || null} />
{/* Analyst Consensus */} {/* Analyst Consensus */}
{consensus && ( {consensus && (
@@ -522,14 +555,14 @@ function SliderInput({ label, value, onChange, min, max, step, suffix }: {
<div> <div>
<div className="flex justify-between text-sm mb-2"> <div className="flex justify-between text-sm mb-2">
<span className="text-text-muted">{label}</span> <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> </div>
<input <input
type="range" type="range"
min={min} max={max} step={step} min={min} max={max} step={step}
value={value} value={value}
onChange={(e) => onChange(parseFloat(e.target.value))} onChange={(e) => onChange(parseFloat(e.target.value))}
className="w-full accent-[#00D4AA]" className="w-full accent-brand-navy"
/> />
</div> </div>
); );
+39 -14
View File
@@ -7,30 +7,55 @@ const config: Config = {
theme: { theme: {
extend: { extend: {
colors: { 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: { bg: {
primary: "#0A0A0F", primary: "#FAFAFA",
secondary: "#12121A", secondary: "#F1F3F6",
card: "#1A1A26", card: "#FFFFFF",
hover: "#252536", hover: "#F1F3F6",
}, },
accent: { accent: {
green: "#00D4AA", green: "#1B2A4A",
red: "#FF4757", red: "#C0392B",
yellow: "#FFD93D", yellow: "#C4A35A",
blue: "#4DA6FF", blue: "#2E5B9A",
}, },
text: { text: {
primary: "#F3F4F6", primary: "#1A1A2E",
secondary: "#9CA3AF", secondary: "#4A5568",
muted: "#6B7280", muted: "#6B7B8D",
}, },
border: { border: {
DEFAULT: "#2A2A3A", DEFAULT: "#E8ECF0",
strong: "#CBD5DF",
subtle: "#F1F3F6",
}, },
}, },
fontFamily: { fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"], serif: ["var(--font-serif)", "Georgia", "serif"],
mono: ["JetBrains Mono", "monospace"], 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 import re
from typing import Any, Dict, List from typing import Any, Dict, List
import httpx
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -32,39 +33,58 @@ class SimpleQuestionRequest(BaseModel):
api_key: str = "" api_key: str = ""
def _call_gemini( async def _call_gemini(
api_key: str, api_key: str,
prompt: str, prompt: str,
max_tokens: int = 4096, max_tokens: int = 4096,
temperature: float = 0.7, temperature: float = 0.7,
) -> str: ) -> str:
"""Call Gemini API directly and return text response.""" """Call Gemini API directly (non-blocking) and return text response."""
import urllib.request url = (
import urllib.error "https://generativelanguage.googleapis.com/v1beta/models/"
f"gemini-2.0-flash:generateContent?key={api_key}"
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={api_key}" )
payload = {
payload = json.dumps({
"contents": [{"parts": [{"text": prompt}]}], "contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {"maxOutputTokens": max_tokens, "temperature": temperature}, "generationConfig": {
}).encode("utf-8") "maxOutputTokens": max_tokens,
"temperature": temperature,
},
}
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"})
try: try:
with urllib.request.urlopen(req, timeout=60) as resp: async with httpx.AsyncClient(timeout=60.0) as client:
data = json.loads(resp.read().decode("utf-8")) resp = await client.post(
candidates = data.get("candidates", []) url,
if candidates: json=payload,
parts = candidates[0].get("content", {}).get("parts", []) headers={"Content-Type": "application/json"},
if parts: )
return parts[0].get("text", "") except httpx.TimeoutException as exc:
return "No response from Gemini." logger.warning("Gemini API timeout: %s", exc)
except urllib.error.HTTPError as e: raise HTTPException(status_code=504, detail="Gemini API timeout") from exc
body = e.read().decode("utf-8", errors="replace") except httpx.HTTPError as exc:
logger.error("Gemini API error %d: %s", e.code, body) logger.exception("Gemini API request error")
raise HTTPException(status_code=e.code, detail=f"Gemini API error: {body[:200]}") raise HTTPException(status_code=502, detail=f"Gemini request failed: {exc}") from exc
except Exception as e:
raise HTTPException(status_code=500, detail=f"Gemini call failed: {e}") 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( 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.""" 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} 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.""" 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} return {"ticker": req.ticker.upper(), "analysis": result}
@@ -249,7 +269,7 @@ Provide insights on:
Use markdown format with headers and bullet points.""" 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} return {"ticker": req.ticker.upper(), "report": result}
@@ -276,7 +296,7 @@ Check for:
Use markdown format. Be thorough but fair.""" 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} 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.") raise HTTPException(status_code=400, detail="API key required.")
context = _get_financial_context(req.ticker.upper()) 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} return {"ticker": req.ticker.upper(), "financials": result}
@@ -360,7 +380,7 @@ Output rules:
raw = "" raw = ""
try: 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) parsed = _parse_llm_json_object(raw)
return _anomaly_response_from_parsed(parsed) return _anomaly_response_from_parsed(parsed)
except json.JSONDecodeError: except json.JSONDecodeError:
@@ -420,7 +440,7 @@ async def translate_text(req: TranslateRequest):
) )
try: 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} return {"translated_text": result}
except Exception as exc: except Exception as exc:
logger.exception("Translation failed") 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." prompt += "\n\nIMPORTANT: Write the entire analysis in Japanese (日本語). Keep financial terms in English."
try: 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: except HTTPException:
raise raise
except Exception as exc: except Exception as exc:
+40 -27
View File
@@ -1,12 +1,16 @@
"""Crypto router -- live cryptocurrency prices from Bithumb (KRW) and Binance (USD).""" """Crypto router -- live cryptocurrency prices from Bithumb (KRW) and Binance (USD)."""
import asyncio
import logging
from typing import List from typing import List
import httpx
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from server.models.schemas import CryptoPrice from server.models.schemas import CryptoPrice
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__)
# Top 20 symbols tracked by default # Top 20 symbols tracked by default
TOP_SYMBOLS = [ 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.""" """Fetch USD prices from Binance API for the given symbols."""
import requests
url = "https://api.binance.com/api/v3/ticker/price" url = "https://api.binance.com/api/v3/ticker/price"
try: try:
resp = requests.get(url, timeout=10) resp = await client.get(url, timeout=10)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
except Exception: except (httpx.HTTPError, ValueError) as exc:
logger.warning("Binance prices fetch failed: %s", exc)
return {} return {}
# Build lookup: symbol (no USDT suffix) -> price
prices = {} prices = {}
lookup = {item["symbol"]: float(item["price"]) for item in data} lookup = {item["symbol"]: float(item["price"]) for item in data}
for sym in symbols: for sym in symbols:
@@ -44,37 +46,40 @@ def _fetch_binance_prices(symbols: List[str]) -> dict:
return prices return prices
def _fetch_bithumb_prices(symbols: List[str]) -> dict: async def _fetch_bithumb_prices(client: httpx.AsyncClient, symbols: List[str]) -> dict:
"""Fetch KRW prices from Bithumb public API.""" """Fetch KRW prices from Bithumb public API (parallel)."""
import requests async def _one(sym: str):
prices = {}
for sym in symbols:
bithumb_sym = _BITHUMB_MAP.get(sym.upper(), sym.upper()) bithumb_sym = _BITHUMB_MAP.get(sym.upper(), sym.upper())
url = f"https://api.bithumb.com/public/ticker/{bithumb_sym}_KRW" url = f"https://api.bithumb.com/public/ticker/{bithumb_sym}_KRW"
try: try:
resp = requests.get(url, timeout=5) resp = await client.get(url, timeout=5)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
if data.get("status") == "0000": if data.get("status") == "0000":
closing = data.get("data", {}).get("closing_price") closing = data.get("data", {}).get("closing_price")
if closing: if closing:
prices[sym.upper()] = float(closing) return sym.upper(), float(closing)
except Exception: except (httpx.HTTPError, ValueError) as exc:
continue 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 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.""" """Fetch 24h percentage changes from Binance."""
import requests
url = "https://api.binance.com/api/v3/ticker/24hr" url = "https://api.binance.com/api/v3/ticker/24hr"
try: try:
resp = requests.get(url, timeout=10) resp = await client.get(url, timeout=10)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
except Exception: except (httpx.HTTPError, ValueError) as exc:
logger.warning("Binance 24h changes fetch failed: %s", exc)
return {} return {}
changes = {} changes = {}
@@ -97,9 +102,12 @@ async def crypto_prices():
USD prices come from Binance; KRW prices from Bithumb. USD prices come from Binance; KRW prices from Bithumb.
""" """
try: try:
usd_prices = _fetch_binance_prices(TOP_SYMBOLS) async with httpx.AsyncClient() as client:
krw_prices = _fetch_bithumb_prices(TOP_SYMBOLS) usd_prices, krw_prices, changes = await asyncio.gather(
changes = _fetch_binance_24h_changes(TOP_SYMBOLS) _fetch_binance_prices(client, TOP_SYMBOLS),
_fetch_bithumb_prices(client, TOP_SYMBOLS),
_fetch_binance_24h_changes(client, TOP_SYMBOLS),
)
results: List[CryptoPrice] = [] results: List[CryptoPrice] = []
for sym in TOP_SYMBOLS: for sym in TOP_SYMBOLS:
@@ -112,6 +120,7 @@ async def crypto_prices():
)) ))
return results return results
except Exception as exc: except Exception as exc:
logger.exception("Crypto prices endpoint failed")
raise HTTPException(status_code=500, detail=f"Crypto prices failed: {exc}") from exc 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.""" """Return current price for a single cryptocurrency symbol."""
try: try:
sym = symbol.upper() sym = symbol.upper()
usd_prices = _fetch_binance_prices([sym]) async with httpx.AsyncClient() as client:
krw_prices = _fetch_bithumb_prices([sym]) usd_prices, krw_prices, changes = await asyncio.gather(
changes = _fetch_binance_24h_changes([sym]) _fetch_binance_prices(client, [sym]),
_fetch_bithumb_prices(client, [sym]),
_fetch_binance_24h_changes(client, [sym]),
)
return CryptoPrice( return CryptoPrice(
symbol=sym, symbol=sym,
@@ -136,4 +148,5 @@ async def crypto_price(symbol: str):
change_24h_pct=changes.get(sym), change_24h_pct=changes.get(sym),
) )
except Exception as exc: except Exception as exc:
logger.exception("Crypto price endpoint failed")
raise HTTPException(status_code=500, detail=f"Crypto price failed: {exc}") from exc 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.""" """EDINET Japan — optional annual report (有価証券報告書) + link fallbacks."""
import asyncio
import logging
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from server.models.schemas import EdgarSectionsResponse from server.models.schemas import EdgarSectionsResponse
@@ -10,11 +13,12 @@ from server.services.edinet_filing_service import (
) )
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/links/{ticker}", summary="EDINET portal links (no API key required)") @router.get("/links/{ticker}", summary="EDINET portal links (no API key required)")
async def edinet_links(ticker: str): async def edinet_links(ticker: str):
return get_edinet_links(ticker) return await asyncio.to_thread(get_edinet_links, ticker)
@router.get( @router.get(
@@ -30,12 +34,15 @@ async def edinet_sections(
), ),
): ):
try: 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: except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc raise HTTPException(status_code=422, detail=str(exc)) from exc
except FileNotFoundError as exc: except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as 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 raise HTTPException(status_code=500, detail=f"EDINET failed: {exc}") from exc
links = meta.get("links") if isinstance(meta.get("links"), dict) else None links = meta.get("links") if isinstance(meta.get("links"), dict) else None
@@ -2,9 +2,11 @@
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import logging
from fastapi import APIRouter from fastapi import APIRouter
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__)
def _safe_df_to_dict(df: Any) -> List[Dict[str, Any]]: 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 []
return df.fillna(0).reset_index().to_dict(orient="records") return df.fillna(0).reset_index().to_dict(orient="records")
except Exception: except Exception:
logger.exception("estimates endpoint failed")
return [] return []
@@ -84,6 +87,7 @@ async def full_estimates(ticker: str) -> Dict[str, Any]:
"price_targets": price_targets, "price_targets": price_targets,
} }
except Exception: except Exception:
logger.exception("estimates endpoint failed")
return { return {
"ticker": ticker.upper(), "ticker": ticker.upper(),
"earnings_estimate": [], "earnings_estimate": [],
@@ -120,6 +124,7 @@ async def earnings_dates(ticker: str) -> Dict[str, Any]:
"earnings_history": history_records, "earnings_history": history_records,
} }
except Exception: except Exception:
logger.exception("estimates endpoint failed")
return { return {
"ticker": ticker.upper(), "ticker": ticker.upper(),
"earnings_dates": [], "earnings_dates": [],
@@ -152,6 +157,7 @@ async def growth_estimates(ticker: str) -> Dict[str, Any]:
"eps_trend": eps_records, "eps_trend": eps_records,
} }
except Exception: except Exception:
logger.exception("estimates endpoint failed")
return { return {
"ticker": ticker.upper(), "ticker": ticker.upper(),
"growth_estimates": [], "growth_estimates": [],
@@ -1,10 +1,12 @@
"""Financial statements router -- statements, highlights, and ratios.""" """Financial statements router -- statements, highlights, and ratios."""
import logging
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from fastapi import APIRouter from fastapi import APIRouter
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__)
def _df_to_periods(df: Any, max_periods: int = 5) -> List[Dict[str, Any]]: 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, "revenue_yoy_growth": revenue_growth,
} }
except Exception: except Exception:
logger.exception("financials endpoint failed")
return { return {
"ticker": ticker.upper(), "ticker": ticker.upper(),
"income_statement": [], "income_statement": [],
@@ -173,6 +176,7 @@ async def financial_highlights(ticker: str) -> Dict[str, Any]:
"book_value": _safe_get(info, "bookValue"), "book_value": _safe_get(info, "bookValue"),
"earnings_growth": _safe_get(info, "earningsGrowth"), "earnings_growth": _safe_get(info, "earningsGrowth"),
"revenue_growth": _safe_get(info, "revenueGrowth"), "revenue_growth": _safe_get(info, "revenueGrowth"),
"currency": info.get("currency") or info.get("financialCurrency") or "USD",
} }
# Derive EBITDA margin if both values exist # Derive EBITDA margin if both values exist
@@ -183,6 +187,7 @@ async def financial_highlights(ticker: str) -> Dict[str, Any]:
return highlights return highlights
except Exception: except Exception:
logger.exception("financials endpoint failed")
return { return {
"ticker": ticker.upper(), "ticker": ticker.upper(),
"company_name": "", "company_name": "",
@@ -207,6 +212,7 @@ async def kpi_history(ticker: str) -> Dict[str, Any]:
return build_kpi_history(ticker) return build_kpi_history(ticker)
except Exception: except Exception:
logger.exception("financials endpoint failed")
return { return {
"ticker": ticker.upper(), "ticker": ticker.upper(),
"quarters": [], "quarters": [],
@@ -270,6 +276,7 @@ async def financial_ratios(ticker: str) -> Dict[str, Any]:
return ratios return ratios
except Exception: except Exception:
logger.exception("financials endpoint failed")
return { return {
"ticker": ticker.upper(), "ticker": ticker.upper(),
"trailing_pe": None, "forward_pe": None, "trailing_pe": None, "forward_pe": None,
+64 -42
View File
@@ -2,6 +2,7 @@
import asyncio import asyncio
import os import os
from functools import partial
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from fastapi import APIRouter, Query 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 from server.services.yield_fx_service import get_yield_fx_pair
router = APIRouter() 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)") @router.get("/quadrant", summary="Global growth vs inflation quadrant (Z-scores)")
async def macro_quadrant() -> Dict[str, Any]: async def macro_quadrant() -> Dict[str, Any]:
try: return await _run_macro_task(
return await asyncio.to_thread(get_global_macro_quadrant) get_global_macro_quadrant,
except Exception as exc: {"updated_at": None, "points": []},
return {"updated_at": None, "points": [], "error": str(exc)} )
@router.get("/yield-fx", summary="US10Y spread vs FX (usdjpy|eurusd|usdkrw)") @router.get("/yield-fx", summary="US10Y spread vs FX (usdjpy|eurusd|usdkrw)")
async def macro_yield_fx( async def macro_yield_fx(
pair: str = Query("usdjpy", description="usdjpy, eurusd, or usdkrw"), pair: str = Query("usdjpy", description="usdjpy, eurusd, or usdkrw"),
) -> Dict[str, Any]: ) -> Dict[str, Any]:
try: return await _run_macro_task(
return await asyncio.to_thread(get_yield_fx_pair, pair) get_yield_fx_pair,
except Exception as exc: {
return {
"pair": pair, "pair": pair,
"updated_at": None, "updated_at": None,
"series": [], "series": [],
"error": str(exc), },
} pair,
)
@router.get("/smart-money", summary="Copper/Gold + RORO composite") @router.get("/smart-money", summary="Copper/Gold + RORO composite")
async def macro_smart_money() -> Dict[str, Any]: async def macro_smart_money() -> Dict[str, Any]:
try: return await _run_macro_task(
return await asyncio.to_thread(get_smart_money_snapshot) get_smart_money_snapshot,
except Exception as exc: {
return {
"updated_at": None, "updated_at": None,
"roro_z": None, "roro_z": None,
"roro_label": None, "roro_label": None,
"copper_gold": [], "copper_gold": [],
"components": {}, "components": {},
"error": str(exc), },
} )
@router.get("/subfactors", summary="4-category macro subfactor breakdown + cycle stage") @router.get("/subfactors", summary="4-category macro subfactor breakdown + cycle stage")
async def macro_subfactors() -> Dict[str, Any]: async def macro_subfactors() -> Dict[str, Any]:
from server.services.macro_cycle import get_subfactor_breakdown from server.services.macro_cycle import get_subfactor_breakdown
try: return await _run_macro_task(
return await asyncio.to_thread(get_subfactor_breakdown) get_subfactor_breakdown,
except Exception as exc: {
return {
"updated_at": None, "updated_at": None,
"composite_score": 0.0, "composite_score": 0.0,
"cycle_stage": "Unknown", "cycle_stage": "Unknown",
"categories": {}, "categories": {},
"error": str(exc), },
} )
@router.get("/fred/{series_id}", summary="FRED time series (public CSV)") @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"), start: Optional[str] = Query(None, description="YYYY-MM-DD"),
end: Optional[str] = Query(None, description="YYYY-MM-DD"), end: Optional[str] = Query(None, description="YYYY-MM-DD"),
) -> Dict[str, Any]: ) -> Dict[str, Any]:
rows = await asyncio.to_thread( try:
macro_fetcher.fetch_fred_series, rows = await asyncio.wait_for(
series_id, asyncio.to_thread(
start, macro_fetcher.fetch_fred_series,
end, 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} 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]: async def macro_oecd_cli() -> Dict[str, Any]:
from server.services.oecd_cycle import get_oecd_cli_snapshot from server.services.oecd_cycle import get_oecd_cli_snapshot
try: return await _run_macro_task(
return await asyncio.to_thread(get_oecd_cli_snapshot) get_oecd_cli_snapshot,
except Exception as exc: {"updated_at": None, "countries": []},
return {"updated_at": None, "countries": [], "error": str(exc)} )
@router.get("/snapshot", summary="Macro cycle heatmap, countries, asset valuation") @router.get("/snapshot", summary="Macro cycle heatmap, countries, asset valuation")
async def macro_snapshot() -> Dict[str, Any]: async def macro_snapshot() -> Dict[str, Any]:
from server.services.macro_cycle import get_macro_cycle_snapshot from server.services.macro_cycle import get_macro_cycle_snapshot
try: return await _run_macro_task(
return await asyncio.to_thread(get_macro_cycle_snapshot) get_macro_cycle_snapshot,
except Exception as exc: {
return {
"updated_at": None, "updated_at": None,
"cycle_score": 0.0, "cycle_score": 0.0,
"regime": "Unknown", "regime": "Unknown",
"cycle_heatmap": [], "cycle_heatmap": [],
"country_heatmap": [], "country_heatmap": [],
"asset_valuation": [], "asset_valuation": [],
"error": str(exc), },
} )
@router.get("/korea", summary="Korea indicators (ECOS or yfinance fallback)") @router.get("/korea", summary="Korea indicators (ECOS or yfinance fallback)")
@@ -134,10 +155,11 @@ async def macro_economic_calendar(
) -> Dict[str, Any]: ) -> Dict[str, Any]:
from server.services.economic_calendar import get_economic_calendar from server.services.economic_calendar import get_economic_calendar
try: return await _run_macro_task(
return await asyncio.to_thread(get_economic_calendar, days) get_economic_calendar,
except Exception as exc: {"events": [], "next_high_impact": None, "total": 0},
return {"events": [], "next_high_impact": None, "total": 0, "error": str(exc)} days,
)
@router.get("/ecos", summary="Korea Bank ECOS (requires ECOS_API_KEY)") @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.""" """Market Data router -- sector info, financial trends, comps, health metrics."""
import logging
from typing import Any, Dict, List from typing import Any, Dict, List
from fastapi import APIRouter, Query from fastapi import APIRouter, Query
from server.utils.ticker_utils import AssetType, detect_asset_type from server.utils.ticker_utils import AssetType, detect_asset_type
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__)
def _safe_float(val, default=0.0): def _safe_float(val, default=0.0):
@@ -45,10 +47,12 @@ async def market_indices():
"change": f"{pct:+.2f}%", "change": f"{pct:+.2f}%",
"positive": pct >= 0, "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}) results.append({"label": s["label"], "symbol": s["symbol"], "price": "", "change": "", "positive": True})
return results return results
except Exception: except Exception:
logger.exception("indices: outer failure")
return [] return []
@@ -59,6 +63,7 @@ async def market_overview():
return await get_market_overview() return await get_market_overview()
except Exception as e: except Exception as e:
logger.exception("market_overview failed")
return {"error": str(e), "data": None} return {"error": str(e), "data": None}
@@ -69,6 +74,7 @@ async def sector_heatmap():
return await get_sector_heatmap() return await get_sector_heatmap()
except Exception as e: except Exception as e:
logger.exception("sector_heatmap failed")
return {"error": str(e), "data": None} 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)} return {"asset_type": AssetType.EQUITY.value, "data": await get_equity_overview(ticker)}
except Exception as e: except Exception as e:
logger.exception("overview/%s failed", ticker)
return {"error": str(e), "asset_type": AssetType.EQUITY.value, "data": None} 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)} return {"ticker": ticker.upper(), "holdings": await get_etf_holdings(ticker)}
except Exception as e: except Exception as e:
logger.exception("etf/%s/holdings failed", ticker)
return {"error": str(e), "ticker": ticker.upper(), "holdings": []} return {"error": str(e), "ticker": ticker.upper(), "holdings": []}
@@ -114,6 +122,7 @@ async def commodity_seasonal(ticker: str):
data = await get_commodity_overview(ticker) data = await get_commodity_overview(ticker)
return {"ticker": ticker.upper(), "seasonal_pattern": data.get("seasonal_pattern", {})} return {"ticker": ticker.upper(), "seasonal_pattern": data.get("seasonal_pattern", {})}
except Exception as e: except Exception as e:
logger.exception("commodity/%s/seasonal failed", ticker)
return {"error": str(e), "ticker": ticker.upper(), "seasonal_pattern": {}} 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)} return {"ticker": ticker.upper(), "correlations": await compute_commodity_correlations(ticker)}
except Exception as e: except Exception as e:
logger.exception("commodity/%s/correlations failed", ticker)
return {"error": str(e), "ticker": ticker.upper(), "correlations": {}} return {"error": str(e), "ticker": ticker.upper(), "correlations": {}}
@@ -164,8 +174,11 @@ async def sector_industry(ticker: str):
"website": info.get("website"), "website": info.get("website"),
"ipo_date": info.get("ipoExpectedDate") or info.get("firstTradeDateEpochUtc"), "ipo_date": info.get("ipoExpectedDate") or info.get("firstTradeDateEpochUtc"),
"description": info.get("longBusinessSummary"), "description": info.get("longBusinessSummary"),
"currency": info.get("currency") or info.get("financialCurrency") or "USD",
"exchange": info.get("exchange"),
} }
except Exception: except Exception:
logger.exception("sector/%s failed", ticker)
return {"sector": "N/A", "industry": "N/A"} 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} return {"years": years, "revenue": revenue, "net_income": net_income, "operating_margin": op_margin, "fcf": fcf_list}
except Exception: except Exception:
logger.exception("trend/%s failed", ticker)
return {"years": [], "revenue": [], "net_income": [], "operating_margin": [], "fcf": []} return {"years": [], "revenue": [], "net_income": [], "operating_margin": [], "fcf": []}
@@ -212,6 +226,7 @@ async def peer_valuation_multiples(ticker: str):
return build_peer_comparison(ticker) return build_peer_comparison(ticker)
except Exception: except Exception:
logger.exception("peers/%s failed", ticker)
return { return {
"ticker": ticker.upper(), "ticker": ticker.upper(),
"sector": "", "sector": "",
@@ -243,6 +258,7 @@ async def industry_comps(tickers: str = Query(..., description="Comma-separated
}) })
return {"tickers": ticker_list, "data": results} return {"tickers": ticker_list, "data": results}
except Exception: except Exception:
logger.exception("comps failed for %s", tickers)
return {"tickers": [], "data": []} 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, "debt_to_equity": round(debt_to_equity, 2) if debt_to_equity is not None else None,
"red_flags": red_flags, "red_flags": red_flags,
} }
except Exception as e: except Exception:
logger.exception("health/%s failed", ticker)
return fallback return fallback
@@ -455,6 +472,7 @@ async def piotroski_score(ticker: str):
return {"total": score, "details": details, "score": score} return {"total": score, "details": details, "score": score}
except Exception: except Exception:
logger.exception("piotroski/%s failed", ticker)
return {"total": 0, "details": {}, "score": 0} 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, "change_pct": round(ch, 2) if ch is not None else None,
} }
except Exception: except Exception:
logger.exception("quote/%s failed", ticker)
return {"ticker": ticker.upper(), "current_price": None, "change_pct": None} 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) nivo = sankey_nivo_for_ticker(ticker)
except Exception: except Exception:
logger.warning("sankey/%s nivo fallback used", ticker)
nivo = {"nodes": [], "links": []} nivo = {"nodes": [], "links": []}
return {"nodes": nodes, "nivo": nivo} return {"nodes": nodes, "nivo": nivo}
except Exception: except Exception:
logger.exception("sankey/%s failed", ticker)
return {"nodes": [], "nivo": {"nodes": [], "links": []}} return {"nodes": [], "nivo": {"nodes": [], "links": []}}
@@ -533,4 +554,5 @@ async def radar_metrics(ticker: str):
"revenue_growth": _safe_float(info.get("revenueGrowth", 0)) * 100, "revenue_growth": _safe_float(info.get("revenueGrowth", 0)) * 100,
} }
except Exception: except Exception:
logger.exception("radar/%s failed", ticker)
return {} return {}
+14 -3
View File
@@ -1,5 +1,7 @@
"""News router -- Finviz scrape + Google News RSS + Yahoo Finance RSS.""" """News router -- Finviz scrape + Google News RSS + Yahoo Finance RSS."""
import asyncio
import logging
from typing import List from typing import List
from fastapi import APIRouter, HTTPException, Query 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 from server.services.news_aggregator import merge_news_for_router
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__)
@router.get( @router.get(
@@ -18,9 +21,12 @@ router = APIRouter()
async def get_news(ticker: str): async def get_news(ticker: str):
"""Return recent articles: Finviz table, Google News RSS, Yahoo headline RSS.""" """Return recent articles: Finviz table, Google News RSS, Yahoo headline RSS."""
try: 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] return [NewsItem(**item) for item in merged]
except Exception as exc: 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 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.""" """Fetch merged news and optionally summarize with Gemini."""
try: 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] = [] headlines: List[str] = []
for item in all_items: for item in all_items:
title = (item.get("title") or "").strip() 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: Headlines:
{headline_text}""" {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 "" summary = (response.text or "").strip() if response else ""
return { return {
@@ -71,4 +81,5 @@ Headlines:
"items": all_items, "items": all_items,
} }
except Exception as exc: 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 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 pathlib import Path
from typing import List from typing import List
import logging
from fastapi import APIRouter, HTTPException, UploadFile, File, Header from fastapi import APIRouter, HTTPException, UploadFile, File, Header
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -16,6 +17,7 @@ from server.models.schemas import (
) )
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__)
# Simple file-based persistence (production would use Supabase / Postgres) # Simple file-based persistence (production would use Supabase / Postgres)
_PORTFOLIO_FILE = Path(__file__).resolve().parent.parent.parent / "data" / "portfolio.json" _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: with open(_PORTFOLIO_FILE, "r", encoding="utf-8") as f:
return json.load(f) return json.load(f)
except Exception: except Exception:
logger.exception("portfolio endpoint failed")
return [] return []
+4 -1
View File
@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from fastapi import APIRouter from fastapi import APIRouter
from server.services.research_dashboard import build_research_dashboard 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") @router.get("/dashboard/{ticker}", summary="F-Score history, DuPont tree, Sankey, waterfall, anomalies")
async def research_dashboard(ticker: str): async def research_dashboard(ticker: str):
"""Quant-only payload for Research page widgets.""" """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 from __future__ import annotations
import logging
from fastapi import APIRouter from fastapi import APIRouter
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/search") @router.post("/search")
@@ -15,6 +17,7 @@ async def search_stocks(filters: dict):
return await run_screener(filters) return await run_screener(filters)
except Exception as e: except Exception as e:
logger.exception("screener endpoint failed")
return {"error": str(e), "data": []} return {"error": str(e), "data": []}
@@ -34,6 +37,7 @@ async def backtest(body: dict):
rebalance_months=body.get("rebalance_months"), rebalance_months=body.get("rebalance_months"),
) )
except Exception as e: except Exception as e:
logger.exception("screener endpoint failed")
return {"error": str(e)} return {"error": str(e)}
@@ -59,4 +63,5 @@ async def portfolio_backtest(body: dict):
benchmark_ticker=str(body.get("benchmark_ticker") or "SPY"), benchmark_ticker=str(body.get("benchmark_ticker") or "SPY"),
) )
except Exception as e: except Exception as e:
logger.exception("screener endpoint failed")
return {"error": str(e)} return {"error": str(e)}
@@ -1,11 +1,13 @@
"""Valuation router -- DCF calculation, smart defaults, analyst consensus, """Valuation router -- DCF calculation, smart defaults, analyst consensus,
sensitivity analysis, Monte Carlo simulation, reverse DCF, and tornado charts.""" sensitivity analysis, Monte Carlo simulation, reverse DCF, and tornado charts."""
import logging
from fastapi import APIRouter from fastapi import APIRouter
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__)
def _safe_float(val, default=0.0): 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} return {"fcf": fcf, "total_debt": total_debt, "cash": cash, "shares": shares}
except Exception: except Exception:
logger.exception("dcf-inputs fallback")
return {"fcf": None, "total_debt": 0, "cash": 0, "shares": None} return {"fcf": None, "total_debt": 0, "cash": 0, "shares": None}
@@ -118,6 +121,7 @@ async def calculate_dcf(inputs: DCFInputsBody):
}, },
} }
except Exception: except Exception:
logger.exception("calculate_dcf failed")
return {"base": None, "bull": None, "bear": None, "current_price": None} return {"base": None, "bull": None, "bear": None, "current_price": None}
@@ -147,6 +151,7 @@ async def smart_defaults(ticker: str):
"sector": sector, "industry": industry, "sector": sector, "industry": industry,
} }
except Exception: 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"} 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 return result
except Exception as exc: except Exception as exc:
logger.exception("valuation endpoint failed")
return {"error": str(exc)} return {"error": str(exc)}
@@ -209,6 +215,7 @@ async def tornado_chart(body: SensitivityBody):
) )
return {"data": result} return {"data": result}
except Exception as exc: except Exception as exc:
logger.exception("valuation endpoint failed")
return {"error": str(exc)} return {"error": str(exc)}
@@ -245,6 +252,7 @@ async def monte_carlo_dcf(body: MonteCarloBody):
result["values"] = [] result["values"] = []
return result return result
except Exception as exc: except Exception as exc:
logger.exception("valuation endpoint failed")
return {"error": str(exc)} return {"error": str(exc)}
@@ -276,6 +284,7 @@ async def reverse_dcf_endpoint(body: ReverseDCFBody):
"current_price": current_price, "current_price": current_price,
} }
except Exception as exc: except Exception as exc:
logger.exception("valuation endpoint failed")
return {"error": str(exc)} return {"error": str(exc)}
@@ -295,4 +304,5 @@ async def analyst_consensus(ticker: str):
"num_analysts": info.get("numberOfAnalystOpinions", 0), "num_analysts": info.get("numberOfAnalystOpinions", 0),
} }
except Exception: 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} 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()) t = yf.Ticker(ticker.upper())
info = t.info or {} 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 { return {
"name": info.get("longName") or info.get("shortName", ticker.upper()), "name": info.get("longName") or info.get("shortName", ticker.upper()),
"sector": info.get("sector"), "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")), "pe_ratio": _safe_num(info.get("trailingPE")) or _safe_num(info.get("forwardPE")),
"dividend_yield": _safe_num(info.get("dividendYield")), "dividend_yield": _safe_num(info.get("dividendYield")),
"beta": _safe_num(info.get("beta")), "beta": _safe_num(info.get("beta")),
"high_52w": _safe_num(info.get("fiftyTwoWeekHigh")), "high_52w": hi52,
"low_52w": _safe_num(info.get("fiftyTwoWeekLow")), "low_52w": lo52,
"price": _safe_num(info.get("currentPrice") or info.get("regularMarketPrice")), "price": _safe_num(info.get("currentPrice") or info.get("regularMarketPrice")),
"description": info.get("longBusinessSummary"), "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() return web.DataReader(fred_code, "fred", start=start).dropna()
except Exception: except Exception:
url = f"https://fred.stlouisfed.org/graph/fredgraph.csv?id={fred_code}" 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() response.raise_for_status()
df = pd.read_csv(StringIO(response.text)) df = pd.read_csv(StringIO(response.text))
date_column = "DATE" if "DATE" in df.columns else "observation_date" 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"https://api.worldbank.org/v2/country/{country_code}"
f"/indicator/{indicator}?format=json&per_page=5&mrv=3" 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() data = resp.json()
if len(data) > 1 and data[1]: if len(data) > 1 and data[1]:
for item in data[1]: for item in data[1]:
@@ -44,7 +44,7 @@ def fetch_fred_series(
url = f"{_FRED_CSV}?id={sid}" url = f"{_FRED_CSV}?id={sid}"
try: try:
req = Request(url, headers={"User-Agent": _USER_AGENT}) 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") text = resp.read().decode("utf-8", errors="replace")
except Exception: except Exception:
return [] return []
@@ -104,7 +104,7 @@ def fetch_ecos_series(
url = "https://ecos.bok.or.kr" + path url = "https://ecos.bok.or.kr" + path
try: try:
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT}) 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") raw = resp.read().decode("utf-8", errors="replace")
except Exception: except Exception:
return [] return []
@@ -5,14 +5,14 @@ All quantitative; no LLM. See claude.md hybrid separation principle.
from __future__ import annotations from __future__ import annotations
import threading
import time
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import pandas as pd import pandas as pd
from server.utils.safe_float import _safe_float 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.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 ( from server.models.schemas import (
DuPontTreeNode, DuPontTreeNode,
DuPontTreePayload, DuPontTreePayload,
@@ -31,6 +31,11 @@ try:
except ImportError: except ImportError:
yf = None # type: ignore[assignment] 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 = [ _FSCORE_KEYS = [
("profitable", "Net income > 0"), ("profitable", "Net income > 0"),
("ocf_pos", "Operating cash flow > 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: def _build_dupont_tree_from_statements(fin: pd.DataFrame, bal: pd.DataFrame) -> Optional[DuPontTreePayload]:
d = get_income_statement_sankey_data(ticker) if fin is None or fin.empty or bal is None or bal.empty:
rev = max(d.get("revenue") or 0, 1) return None
cogs = min(abs(d.get("cogs") or 0), rev * 0.999)
gp = max(d.get("gross_profit") or 0, 0) rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue")
opex = max(d.get("opex") or 0, 0) ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders")
oi = d.get("operating_income") or 0 total_assets = _get_row_series(bal, "Total Assets")
tax = max(d.get("tax_interest_other") or 0, 0) total_equity = _get_row_series(
ni = d.get("net_income") or 0 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 = [ nodes = [
SankeyNivoNode(id="revenue", label="Revenue"), 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="tax_other", label="Tax & other"),
SankeyNivoNode(id="net_income", label="Net income"), 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] = [ links: List[SankeyNivoLink] = [
SankeyNivoLink(source="revenue", target="cogs", value=float(cogs)), SankeyNivoLink(source="revenue", target="cogs", value=float(min(cogs_val, revenue_safe * 0.999))),
SankeyNivoLink(source="revenue", target="gross_profit", value=float(max(gp, rev - cogs))), SankeyNivoLink(source="revenue", target="gross_profit", value=float(max(gross_safe, revenue_safe - cogs_val))),
] ]
gp_v = links[-1].value 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="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 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="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) 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]: def _build_waterfall(fin: pd.DataFrame) -> List[WaterfallStep]:
if fin is None or fin.empty or len(fin.columns) < 2: if fin is None or fin.empty or len(fin.columns) < 2:
return [] return []
@@ -352,8 +429,27 @@ def sankey_nivo_for_ticker(ticker: str) -> Dict[str, Any]:
return _build_sankey_nivo(ticker).model_dump() return _build_sankey_nivo(ticker).model_dump()
def build_research_dashboard(ticker: str) -> ResearchDashboardResponse: def _get_cached_dashboard(sym: str) -> Optional[ResearchDashboardResponse]:
sym = ticker.upper().strip() 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) fin, bal, cf = _get_annual_financials_balance_cashflow(sym)
if fin is None or fin.empty or bal is None or bal.empty: if fin is None or fin.empty or bal is None or bal.empty:
return ResearchDashboardResponse(ticker=sym, error="Insufficient financial statements") return ResearchDashboardResponse(ticker=sym, error="Insufficient financial statements")
@@ -362,11 +458,8 @@ def build_research_dashboard(ticker: str) -> ResearchDashboardResponse:
cf = pd.DataFrame() cf = pd.DataFrame()
total, fseries = _build_fscore_series(sym, fin, bal, cf) total, fseries = _build_fscore_series(sym, fin, bal, cf)
dq = get_dupont_altman_redflags_yoy(sym) tree = _build_dupont_tree_from_statements(fin, bal)
dupont_df = dq.get("dupont") if dq else None sankey = _build_sankey_nivo_from_fin(fin)
tree = _dupont_tree_from_df(dupont_df) if isinstance(dupont_df, pd.DataFrame) else None
sankey = _build_sankey_nivo(sym)
waterfall = _build_waterfall(fin) waterfall = _build_waterfall(fin)
anomalies = _detect_anomalies(fin, bal) anomalies = _detect_anomalies(fin, bal)
@@ -379,3 +472,40 @@ def build_research_dashboard(ticker: str) -> ResearchDashboardResponse:
waterfall=waterfall, waterfall=waterfall,
anomalies=anomalies, 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()