mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-13 18:38:06 +00:00
Refresh Morgan Terminal UI and README media
This commit is contained in:
+87
-26
@@ -1,41 +1,102 @@
|
||||
# ATLAS Terminal — Web Application
|
||||
# ATLAS Terminal
|
||||
|
||||
> Next.js 14 + FastAPI full-stack financial analysis terminal.
|
||||
>
|
||||
> See the [main README](../README.md) for full documentation.
|
||||
Institutional-style equity research terminal built with Next.js 14 and FastAPI.
|
||||
|
||||
ATLAS Terminal brings market overview, quant research, valuation, technical analysis, macro monitoring, filings workflows, and printable institutional reports into one desktop-first interface.
|
||||
|
||||
## What It Does
|
||||
|
||||
- Multi-asset overview for equities, ETFs, commodities, crypto, FX, and macro signals
|
||||
- Quant research dashboards with F-Score, DuPont, anomalies, Sankey, and waterfall views
|
||||
- Valuation tooling including DCF, sensitivity, Monte Carlo, tornado, and reverse DCF
|
||||
- Technical analysis with candlesticks, moving averages, Bollinger Bands, RSI, MACD, and Fibonacci levels
|
||||
- Cross-market monitoring through macro, smart-money, yield/FX, earnings, news, filings, and portfolio pages
|
||||
- Institutional report generation with printable PDF-style layouts
|
||||
|
||||
## Core Product Principle
|
||||
|
||||
> LLMs handle text. Python handles numbers.
|
||||
|
||||
Qualitative analysis, summarization, and narrative framing can be AI-assisted, while valuation logic, financial metrics, and quantitative workflows are computed deterministically in code.
|
||||
|
||||
## Product Tour
|
||||
|
||||
### Overview
|
||||
|
||||

|
||||
|
||||
### Valuation
|
||||
|
||||

|
||||
|
||||
### Technical Analysis
|
||||
|
||||

|
||||
|
||||
### Institutional Report
|
||||
|
||||

|
||||
|
||||
## 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:
|
||||
|
||||
[](./docs/media/atlas-demo.mp4)
|
||||
|
||||
## Stack
|
||||
|
||||
- Frontend: Next.js 14, React 18, TypeScript, Tailwind CSS, Recharts, Lightweight Charts
|
||||
- Backend: FastAPI, Python 3.12+, Pydantic, yfinance, pandas, scipy
|
||||
- Data: SEC, DART, EDINET, FRED, OECD, DBnomics, Yahoo Finance
|
||||
- AI: Gemini for qualitative analysis only
|
||||
- Storage: SQLite by default
|
||||
|
||||
## Key Pages
|
||||
|
||||
- `/` overview dashboard
|
||||
- `/research` quant research workbench
|
||||
- `/valuation` DCF and scenario analysis
|
||||
- `/technical` chart-driven technical analysis
|
||||
- `/macro` macro and smart-money dashboard
|
||||
- `/filings` SEC, DART, and EDINET workflows
|
||||
- `/report` institutional report generator
|
||||
- `/portfolio` portfolio tracking and OCR import
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Backend (from atlas-terminal/)
|
||||
pip install -r requirements.txt
|
||||
PYTHONPATH="." uvicorn server.main:app --port 8000
|
||||
### Backend
|
||||
|
||||
# Frontend (from atlas-terminal/apps/web/)
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
PYTHONPATH="." uvicorn server.main:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- **Frontend:** http://localhost:3000
|
||||
- **Backend:** http://localhost:8000
|
||||
- **API Docs:** http://localhost:8000/docs
|
||||
### Local URLs
|
||||
|
||||
## Stack
|
||||
- Frontend: [http://localhost:3000](http://localhost:3000)
|
||||
- Backend: [http://127.0.0.1:8000](http://127.0.0.1:8000)
|
||||
- API docs: [http://127.0.0.1:8000/docs](http://127.0.0.1:8000/docs)
|
||||
|
||||
- **Frontend:** Next.js 14, TypeScript, Tailwind CSS, TradingView Lightweight Charts
|
||||
- **Backend:** FastAPI, Python 3.12+, yfinance, yahooquery, Google Gemini
|
||||
- **Database:** SQLite (local) / PostgreSQL (production)
|
||||
## Recent Work
|
||||
|
||||
## Recent Updates
|
||||
- Morgan Stanley-inspired redesign across the shell, overview, research, valuation, technical, macro, settings, and report flows
|
||||
- Shared chart palette and UI primitives for a more consistent desktop terminal experience
|
||||
- Research dashboard performance fixes for faster repeat loads and less blocking on page open
|
||||
- Improved macro failure states, report messaging, tooltip formatting, and chart legibility
|
||||
|
||||
**2026-03-24**
|
||||
## Why This Project
|
||||
|
||||
- **Macro:** Global Macro & Smart Money dashboard (`/macro`), Recharts widgets, FastAPI `/api/macro/quadrant`, `/yield-fx`, `/smart-money` (FRED + yfinance + OECD/DBnomics).
|
||||
- **Stability:** Sidebar `dynamic(..., ssr: false)`; `useTicker` hydration-safe init; `app/error.tsx`; macro/research layouts use Tailwind grid (removed `react-grid-layout`).
|
||||
- **News / Filings:** Iframe fallback for blocked publishers (e.g. Yahoo); SEC filings show plain text when HTML snapshot cache is absent.
|
||||
ATLAS Terminal started as an attempt to build a personal Bloomberg-lite for retail investing workflows: high information density, clean narrative structure, and a hard separation between AI-generated language and deterministic financial computation.
|
||||
|
||||
**Earlier**
|
||||
|
||||
- Multi-asset analysis branching across Overview/Research/Valuation/Earnings for equity, ETF, and commodity futures.
|
||||
- Commodity and ETF market widgets; index-level stock heatmap with interactive index switching.
|
||||
- Portfolio OCR upgrades, exchange selection (e.g. SMSN → `SMSN.L`), inline edit/delete; exchange-aware recalculation and FX matrix for multi-currency display.
|
||||
It is currently optimized as a desktop-first personal research environment rather than a SaaS product.
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@nivo/core": "^0.99.0",
|
||||
"@nivo/sankey": "^0.99.0",
|
||||
"lightweight-charts": "^5.1.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"next": "14.2.35",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
|
||||
@@ -16,7 +16,7 @@ const SidebarClient = dynamic(
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<aside
|
||||
className="w-[260px] fixed top-[52px] bottom-0 left-0 z-40 border-r border-border bg-bg-primary"
|
||||
className="fixed bottom-0 left-0 top-[56px] z-40 w-[260px] border-r border-border bg-surface-raised"
|
||||
aria-hidden
|
||||
/>
|
||||
),
|
||||
@@ -27,9 +27,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<TickerBar />
|
||||
<div className="flex min-h-screen pt-[52px]">
|
||||
<div className="flex min-h-screen bg-surface-canvas pt-[56px]">
|
||||
<SidebarClient />
|
||||
<main className="flex-1 ml-[260px] mr-[380px] p-7 bg-bg-primary min-h-[calc(100vh-52px)] transition-all duration-200">
|
||||
<main className="ml-[260px] mr-[380px] min-h-[calc(100vh-56px)] flex-1 bg-transparent p-7 transition-all duration-200">
|
||||
{children}
|
||||
</main>
|
||||
<ChatPanel />
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
"use client";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Bot, SendHorizontal } from "lucide-react";
|
||||
import { useTicker } from "../lib/use-ticker";
|
||||
|
||||
export function ChatPanel() {
|
||||
const [messages, setMessages] = useState<Array<{ role: string; content: string }>>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [ticker, setTicker] = useState("AAPL");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("atlas_active_ticker");
|
||||
if (saved) setTicker(saved);
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (detail) setTicker(detail);
|
||||
};
|
||||
window.addEventListener("atlas-ticker-change", handler);
|
||||
return () => window.removeEventListener("atlas-ticker-change", handler);
|
||||
}, []);
|
||||
const { ticker } = useTicker();
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
|
||||
@@ -60,25 +51,32 @@ export function ChatPanel() {
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="w-[380px] bg-bg-secondary border-l border-border fixed top-[52px] bottom-0 right-0 flex flex-col z-40">
|
||||
<div className="p-4 border-b border-border font-bold text-lg text-text-primary">
|
||||
🤖 AI Copilot
|
||||
<aside className="fixed bottom-0 right-0 top-[56px] z-40 flex w-[380px] flex-col border-l border-border bg-surface-raised">
|
||||
<div className="border-b border-border bg-surface-sunken px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-full bg-brand-navy/10 p-2 text-brand-navy">
|
||||
<Bot className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-lg font-bold text-brand-navy">AI Copilot</div>
|
||||
<div className="text-xs uppercase tracking-[0.12em] text-text-muted">Context: {ticker}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="flex-1 p-4 overflow-y-auto flex flex-col gap-3">
|
||||
<div ref={scrollRef} className="flex flex-1 flex-col gap-3 overflow-y-auto bg-transparent p-4">
|
||||
{messages.length === 0 ? (
|
||||
<div className="text-text-secondary text-sm">
|
||||
<div className="text-sm text-text-secondary">
|
||||
<p>
|
||||
Ask me anything about{" "}
|
||||
<span className="text-accent-green font-semibold">{ticker}</span>.
|
||||
Ask me anything about <span className="font-semibold text-brand-navy">{ticker}</span>.
|
||||
</p>
|
||||
<p className="mt-3 font-semibold text-text-primary">Try:</p>
|
||||
<ul className="flex flex-col gap-1.5 mt-2">
|
||||
<p className="mt-4 text-xs font-semibold uppercase tracking-[0.12em] text-text-muted">Suggested prompts</p>
|
||||
<ul className="mt-2 flex flex-col gap-2">
|
||||
{suggestions.map((q) => (
|
||||
<li
|
||||
key={q}
|
||||
onClick={() => setInput(q)}
|
||||
className="px-3 py-2.5 bg-bg-card rounded-lg cursor-pointer text-sm text-text-primary hover:bg-bg-hover transition-colors"
|
||||
className="cursor-pointer rounded-md border border-border bg-surface-raised px-3 py-2.5 text-sm text-text-primary shadow-card transition-colors hover:bg-surface-sunken"
|
||||
>
|
||||
{q}
|
||||
</li>
|
||||
@@ -89,32 +87,33 @@ export function ChatPanel() {
|
||||
messages.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`px-3.5 py-2.5 rounded-lg text-sm leading-relaxed max-w-[90%] whitespace-pre-wrap ${
|
||||
className={`max-w-[90%] whitespace-pre-wrap rounded-md px-3.5 py-2.5 text-sm leading-relaxed shadow-card ${
|
||||
m.role === "user"
|
||||
? "bg-bg-card text-text-primary self-end"
|
||||
: "bg-accent-green/10 text-text-primary self-start"
|
||||
? "self-end bg-brand-navy text-white"
|
||||
: "self-start border border-border bg-surface-raised text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{m.content}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{loading && <div className="text-accent-green text-sm animate-pulse">Thinking...</div>}
|
||||
{loading && <div className="text-sm text-brand-blue animate-pulse">Thinking...</div>}
|
||||
</div>
|
||||
|
||||
<div className="p-3 border-t border-border">
|
||||
<div className="flex gap-2 bg-bg-card rounded-lg border border-border px-3.5 py-2.5">
|
||||
<div className="border-t border-border bg-surface-raised p-3">
|
||||
<div className="flex gap-2 rounded-md border border-border bg-surface-sunken px-3.5 py-2.5">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSend()}
|
||||
placeholder="Ask anything..."
|
||||
className="flex-1 bg-transparent border-none text-text-primary outline-none"
|
||||
className="flex-1 border-none bg-transparent text-text-primary outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
className="bg-accent-green text-bg-primary border-none rounded-md px-4 py-1.5 font-semibold cursor-pointer text-sm hover:opacity-90 transition-opacity"
|
||||
className="inline-flex items-center gap-1 rounded-md border-none bg-brand-navy px-4 py-1.5 text-sm font-semibold text-white transition-opacity hover:bg-brand-blue"
|
||||
>
|
||||
<SendHorizontal className="h-4 w-4" />
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { chartPalette } from "../../lib/chart-theme";
|
||||
|
||||
export interface QuadrantPoint {
|
||||
id: string;
|
||||
@@ -32,10 +33,10 @@ const FLAG: Record<string, string> = {
|
||||
};
|
||||
|
||||
const QUADRANT_COLOR: Record<string, string> = {
|
||||
Reflation: "#FFD93D",
|
||||
Recovery: "#00D4AA",
|
||||
Stagflation: "#FF4757",
|
||||
Overheat: "#4DA6FF",
|
||||
Reflation: chartPalette.gold,
|
||||
Recovery: chartPalette.green,
|
||||
Stagflation: chartPalette.red,
|
||||
Overheat: chartPalette.blue,
|
||||
};
|
||||
|
||||
function QuadrantTooltip({
|
||||
@@ -54,8 +55,8 @@ function QuadrantTooltip({
|
||||
{flag} {p.label}
|
||||
</div>
|
||||
<div className="text-text-muted">Quadrant: {p.quadrant}</div>
|
||||
<div className="text-accent-green">Growth Z: {p.growth_z?.toFixed(2)}</div>
|
||||
<div className="text-accent-blue">Inflation Z: {p.inflation_z?.toFixed(2)}</div>
|
||||
<div className="text-brand-navy">Growth Z: {p.growth_z?.toFixed(2)}</div>
|
||||
<div className="text-brand-blue">Inflation Z: {p.inflation_z?.toFixed(2)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,31 +74,31 @@ export function GlobalMacroQuadrantChart({ points }: { points: QuadrantPoint[] }
|
||||
<div className="h-[340px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ScatterChart margin={{ top: 16, right: 16, bottom: 8, left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#2A2A3A" />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={chartPalette.grid} />
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="growth_z"
|
||||
name="Growth Z"
|
||||
stroke="#6B7280"
|
||||
tick={{ fill: "#9CA3AF", fontSize: 11 }}
|
||||
label={{ value: "Growth momentum (Z)", position: "bottom", fill: "#6B7280", fontSize: 11 }}
|
||||
stroke={chartPalette.textMuted}
|
||||
tick={{ fill: chartPalette.textMuted, fontSize: 11 }}
|
||||
label={{ value: "Growth momentum (Z)", position: "bottom", fill: chartPalette.textMuted, fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="inflation_z"
|
||||
name="Inflation Z"
|
||||
stroke="#6B7280"
|
||||
tick={{ fill: "#9CA3AF", fontSize: 11 }}
|
||||
label={{ value: "Inflation momentum (Z)", angle: -90, position: "insideLeft", fill: "#6B7280", fontSize: 11 }}
|
||||
stroke={chartPalette.textMuted}
|
||||
tick={{ fill: chartPalette.textMuted, fontSize: 11 }}
|
||||
label={{ value: "Inflation momentum (Z)", angle: -90, position: "insideLeft", fill: chartPalette.textMuted, fontSize: 11 }}
|
||||
/>
|
||||
<ReferenceLine x={0} stroke="#4B5563" strokeDasharray="4 4" />
|
||||
<ReferenceLine y={0} stroke="#4B5563" strokeDasharray="4 4" />
|
||||
<ReferenceLine x={0} stroke={chartPalette.neutral} strokeDasharray="4 4" />
|
||||
<ReferenceLine y={0} stroke={chartPalette.neutral} strokeDasharray="4 4" />
|
||||
<Tooltip content={<QuadrantTooltip />} cursor={{ strokeDasharray: "3 3" }} />
|
||||
<Scatter data={points} fill="#00D4AA" name="Country">
|
||||
<Scatter data={points} fill={chartPalette.green} name="Country">
|
||||
{points.map((entry) => (
|
||||
<Cell key={entry.id} fill={QUADRANT_COLOR[entry.quadrant] ?? "#00D4AA"} />
|
||||
<Cell key={entry.id} fill={QUADRANT_COLOR[entry.quadrant] ?? chartPalette.green} />
|
||||
))}
|
||||
<LabelList dataKey="id" position="top" fill="#E5E7EB" fontSize={11} fontFamily="monospace" />
|
||||
<LabelList dataKey="id" position="top" fill={chartPalette.text} fontSize={11} fontFamily="monospace" />
|
||||
</Scatter>
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { chartPalette } from "../../lib/chart-theme";
|
||||
|
||||
export interface CopperGoldRow {
|
||||
date: string;
|
||||
@@ -34,7 +35,7 @@ function RoroGauge({ z, label }: { z: number | null; label: string | null }) {
|
||||
<path
|
||||
d="M 30 100 A 70 70 0 0 1 170 100"
|
||||
fill="none"
|
||||
stroke="#2A2A3A"
|
||||
stroke={chartPalette.grid}
|
||||
strokeWidth="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
@@ -48,21 +49,21 @@ function RoroGauge({ z, label }: { z: number | null; label: string | null }) {
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="roroGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#FF4757" />
|
||||
<stop offset="50%" stopColor="#FFD93D" />
|
||||
<stop offset="100%" stopColor="#00D4AA" />
|
||||
<stop offset="0%" stopColor={chartPalette.red} />
|
||||
<stop offset="50%" stopColor={chartPalette.gold} />
|
||||
<stop offset="100%" stopColor={chartPalette.green} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<line x1={cx} y1={cy} x2={x2} y2={y2} stroke="#F3F4F6" strokeWidth="3" strokeLinecap="round" />
|
||||
<circle cx={cx} cy={cy} r="6" fill="#00D4AA" />
|
||||
<text x="30" y="108" fill="#6B7280" fontSize="9" fontFamily="monospace">
|
||||
<line x1={cx} y1={cy} x2={x2} y2={y2} stroke={chartPalette.text} strokeWidth="3" strokeLinecap="round" />
|
||||
<circle cx={cx} cy={cy} r="6" fill={chartPalette.navy} />
|
||||
<text x="30" y="108" fill={chartPalette.textMuted} fontSize="9" fontFamily="monospace">
|
||||
Fear
|
||||
</text>
|
||||
<text x="150" y="108" fill="#6B7280" fontSize="9" fontFamily="monospace">
|
||||
<text x="150" y="108" fill={chartPalette.textMuted} fontSize="9" fontFamily="monospace">
|
||||
Greed
|
||||
</text>
|
||||
</svg>
|
||||
<p className="text-accent-green font-mono text-lg mt-1">{label ?? "—"}</p>
|
||||
<p className="mt-1 font-mono text-lg text-brand-navy">{label ?? "—"}</p>
|
||||
<p className="text-text-muted text-xs font-mono">Z = {z != null ? z.toFixed(2) : "—"}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -79,7 +80,7 @@ export function SmartMoneyPanel({
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
|
||||
<div className="bg-bg-secondary/40 rounded-lg border border-border/50 p-4 flex items-center justify-center">
|
||||
<div className="flex items-center justify-center rounded-lg border border-border/50 bg-surface-sunken p-4">
|
||||
<RoroGauge z={roroZ} label={roroLabel} />
|
||||
</div>
|
||||
<div className="min-h-[260px]">
|
||||
@@ -91,19 +92,19 @@ export function SmartMoneyPanel({
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<LineChart data={copperGold} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#2A2A3A" />
|
||||
<XAxis dataKey="date" tick={{ fill: "#9CA3AF", fontSize: 9 }} minTickGap={32} />
|
||||
<YAxis tick={{ fill: "#9CA3AF", fontSize: 10 }} domain={["auto", "auto"]} />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={chartPalette.grid} />
|
||||
<XAxis dataKey="date" tick={{ fill: chartPalette.textMuted, fontSize: 9 }} minTickGap={32} />
|
||||
<YAxis tick={{ fill: chartPalette.textMuted, fontSize: 10 }} domain={["auto", "auto"]} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: "#1A1A26", border: "1px solid #2A2A3A", fontSize: 12 }}
|
||||
contentStyle={{ background: chartPalette.canvas, border: `1px solid ${chartPalette.grid}`, fontSize: 12 }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Line type="monotone" dataKey="ratio" name="Cu/Au" stroke="#FFD93D" dot={false} strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="ratio" name="Cu/Au" stroke={chartPalette.gold} dot={false} strokeWidth={2} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="ratio_ma20"
|
||||
name="MA20"
|
||||
stroke="#4DA6FF"
|
||||
stroke={chartPalette.blue}
|
||||
dot={false}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { chartPalette } from "../../lib/chart-theme";
|
||||
|
||||
export interface YieldFxRow {
|
||||
date: string;
|
||||
@@ -45,24 +46,24 @@ export function YieldFxDualAxisChart({
|
||||
<p className="text-text-muted text-xs font-mono mb-2">{meta.title}</p>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={rows} margin={{ top: 8, right: 12, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#2A2A3A" />
|
||||
<XAxis dataKey="date" tick={{ fill: "#9CA3AF", fontSize: 10 }} minTickGap={24} />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={chartPalette.grid} />
|
||||
<XAxis dataKey="date" tick={{ fill: chartPalette.textMuted, fontSize: 10 }} minTickGap={24} />
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tick={{ fill: "#00D4AA", fontSize: 10 }}
|
||||
tick={{ fill: chartPalette.navy, fontSize: 10 }}
|
||||
domain={["auto", "auto"]}
|
||||
label={{ value: "Spread (ppt)", angle: -90, position: "insideLeft", fill: "#00D4AA", fontSize: 10 }}
|
||||
label={{ value: "Spread (ppt)", angle: -90, position: "insideLeft", fill: chartPalette.navy, fontSize: 10 }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fill: "#4DA6FF", fontSize: 10 }}
|
||||
tick={{ fill: chartPalette.blue, fontSize: 10 }}
|
||||
domain={["auto", "auto"]}
|
||||
label={{ value: meta.fx, angle: 90, position: "insideRight", fill: "#4DA6FF", fontSize: 10 }}
|
||||
label={{ value: meta.fx, angle: 90, position: "insideRight", fill: chartPalette.blue, fontSize: 10 }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ background: "#1A1A26", border: "1px solid #2A2A3A", fontSize: 12 }}
|
||||
labelStyle={{ color: "#F3F4F6" }}
|
||||
contentStyle={{ background: chartPalette.canvas, border: `1px solid ${chartPalette.grid}`, fontSize: 12 }}
|
||||
labelStyle={{ color: chartPalette.text }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Line
|
||||
@@ -70,7 +71,7 @@ export function YieldFxDualAxisChart({
|
||||
type="monotone"
|
||||
dataKey="spread_pct"
|
||||
name="US10Y − peer (ppt)"
|
||||
stroke="#00D4AA"
|
||||
stroke={chartPalette.navy}
|
||||
dot={false}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
@@ -79,7 +80,7 @@ export function YieldFxDualAxisChart({
|
||||
type="monotone"
|
||||
dataKey="fx"
|
||||
name={meta.fx}
|
||||
stroke="#4DA6FF"
|
||||
stroke={chartPalette.blue}
|
||||
dot={false}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { KpiSection, type KpiHistoryData } from "./KpiSection";
|
||||
import { PeerComparison, type PeerComparisonData } from "./PeerComparison";
|
||||
import { Card } from "../ui/Card";
|
||||
import { SectionHeading } from "../ui/SectionHeading";
|
||||
import { StatCard } from "../ui/StatCard";
|
||||
|
||||
interface EquityOverviewProps {
|
||||
ticker: string;
|
||||
@@ -43,31 +46,25 @@ export function EquityOverview({ ticker, sector, health }: EquityOverviewProps)
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-1">
|
||||
<span className="text-accent-green">{ticker}</span> Overview
|
||||
</h1>
|
||||
<div className="atlas-page">
|
||||
<SectionHeading level={1}>{ticker} Overview</SectionHeading>
|
||||
{sector?.current_price != null && (
|
||||
<p className="text-3xl font-mono font-bold text-text-primary mb-6">${Number(sector.current_price).toFixed(2)}</p>
|
||||
<p className="text-3xl font-mono font-bold text-text-primary">${Number(sector.current_price).toFixed(2)}</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||
{metrics.map((m) => (
|
||||
<div key={m.label} className="bg-bg-card border border-border rounded-lg p-4">
|
||||
<div className="text-text-muted text-xs mb-1">{m.label}</div>
|
||||
<div className="text-text-primary font-semibold">{m.value}</div>
|
||||
</div>
|
||||
<StatCard key={m.label} label={m.label} value={m.value} />
|
||||
))}
|
||||
</div>
|
||||
<ConsensusGauge sector={sector} />
|
||||
<KpiSection data={kpiData} />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-4">
|
||||
<Card title="Altman Z-Score" value={health?.altman_z != null ? Number(health.altman_z).toFixed(2) : "—"} />
|
||||
<Card title="Current Ratio" value={health?.current_ratio != null ? Number(health.current_ratio).toFixed(2) : "—"} />
|
||||
<Card title="Interest Cov." value={health?.interest_coverage != null ? `${Number(health.interest_coverage).toFixed(1)}x` : "—"} />
|
||||
<Card title="D/E Ratio" value={health?.debt_to_equity != null ? Number(health.debt_to_equity).toFixed(2) : "—"} />
|
||||
<div className="grid grid-cols-1 gap-4 mb-4 lg:grid-cols-4">
|
||||
<OverviewStat title="Altman Z-Score" value={health?.altman_z != null ? Number(health.altman_z).toFixed(2) : "—"} />
|
||||
<OverviewStat title="Current Ratio" value={health?.current_ratio != null ? Number(health.current_ratio).toFixed(2) : "—"} />
|
||||
<OverviewStat title="Interest Cov." value={health?.interest_coverage != null ? `${Number(health.interest_coverage).toFixed(1)}x` : "—"} />
|
||||
<OverviewStat title="D/E Ratio" value={health?.debt_to_equity != null ? Number(health.debt_to_equity).toFixed(2) : "—"} />
|
||||
</div>
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">DuPont Analysis</h3>
|
||||
<Card title="DuPont Analysis">
|
||||
{!!health?.dupont && typeof health.dupont === "object" && health.dupont !== null ? (
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
@@ -85,7 +82,7 @@ export function EquityOverview({ ticker, sector, health }: EquityOverviewProps)
|
||||
) : (
|
||||
<div className="text-text-muted">No data</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
<PeerComparison currentTicker={ticker} data={peerData} />
|
||||
</div>
|
||||
);
|
||||
@@ -113,9 +110,8 @@ function ConsensusGauge({ sector }: { sector: Record<string, unknown> | null })
|
||||
const targetPct = range > 0 ? Math.max(0, Math.min(100, ((target - gaugeLow) / range) * 100)) : 50;
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5 mb-6">
|
||||
<Card title="Analyst Consensus" className="mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-text-secondary text-sm font-semibold">Analyst Consensus</h3>
|
||||
{count != null && <span className="text-text-muted text-xs">{count} analysts</span>}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-4 mb-4">
|
||||
@@ -135,25 +131,24 @@ function ConsensusGauge({ sector }: { sector: Record<string, unknown> | null })
|
||||
</div>
|
||||
</div>
|
||||
{/* Visual gauge bar */}
|
||||
<div className="relative h-2 bg-bg-hover rounded-full mb-2">
|
||||
<div className="relative mb-2 h-2 rounded-full bg-surface-sunken">
|
||||
{/* target marker */}
|
||||
<div className="absolute top-0 h-2 w-0.5 bg-accent-yellow" style={{ left: `${targetPct}%` }} />
|
||||
<div className="absolute top-0 h-2 w-0.5 bg-brand-gold" style={{ left: `${targetPct}%` }} />
|
||||
{/* current price marker */}
|
||||
<div className="absolute -top-1 h-4 w-1 bg-accent-green rounded-sm" style={{ left: `${currentPct}%` }} />
|
||||
<div className="absolute -top-1 h-4 w-1 rounded-sm bg-brand-navy" style={{ left: `${currentPct}%` }} />
|
||||
</div>
|
||||
<div className="flex justify-between text-text-muted text-xs font-mono">
|
||||
<span>${gaugeLow.toFixed(0)}</span>
|
||||
<span>${gaugeHigh.toFixed(0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ title, value }: { title: string; value: string }) {
|
||||
function OverviewStat({ title, value }: { title: string; value: string }) {
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">{title}</h3>
|
||||
<Card title={title}>
|
||||
<div className="text-3xl font-mono font-bold text-text-primary">{value}</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { chartPalette } from "../../lib/chart-theme";
|
||||
|
||||
export interface KpiHistoryData {
|
||||
ticker: string;
|
||||
quarters: string[];
|
||||
@@ -63,8 +65,8 @@ function Sparkline({ values }: { values: Array<number | null> }) {
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-16">
|
||||
<path d={path} fill="none" stroke="#00D4AA" strokeWidth="2" strokeLinecap="round" />
|
||||
<circle cx={lastX} cy={lastY} r="3" fill="#00D4AA" />
|
||||
<path d={path} fill="none" stroke={chartPalette.blue} strokeWidth="2" strokeLinecap="round" />
|
||||
<circle cx={lastX} cy={lastY} r="3" fill={chartPalette.gold} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { chartPalette } from "../../lib/chart-theme";
|
||||
import type { FScoreCriterionSeries } from "./types";
|
||||
|
||||
function BinarySparkline({ history }: { history: { year: number; pass_flag: boolean }[] }) {
|
||||
@@ -19,14 +20,14 @@ function BinarySparkline({ history }: { history: { year: number; pass_flag: bool
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} className="w-20 h-5 shrink-0" aria-hidden>
|
||||
<path d={lineD} fill="none" stroke="#6B7280" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<path d={lineD} fill="none" stroke={chartPalette.neutral} strokeWidth="1.2" strokeLinecap="round" />
|
||||
{pts.map((p) => (
|
||||
<circle
|
||||
key={p.pt.year}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={3}
|
||||
fill={p.pt.pass_flag ? "#00D4AA" : "#F87171"}
|
||||
fill={p.pt.pass_flag ? chartPalette.green : chartPalette.red}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { ResponsiveSankey } from "@nivo/sankey";
|
||||
import { chartPalette } from "../../lib/chart-theme";
|
||||
import type { SankeyNivoLink, SankeyNivoNode } from "./types";
|
||||
|
||||
const nivoTheme = {
|
||||
background: "transparent",
|
||||
text: { fill: "#9CA3AF", fontSize: 11 },
|
||||
text: { fill: chartPalette.textMuted, fontSize: 11 },
|
||||
tooltip: {
|
||||
container: {
|
||||
background: "#1A1A26",
|
||||
color: "#E5E7EB",
|
||||
background: chartPalette.canvas,
|
||||
color: chartPalette.text,
|
||||
fontSize: 12,
|
||||
border: "1px solid #374151",
|
||||
border: `1px solid ${chartPalette.grid}`,
|
||||
},
|
||||
},
|
||||
labels: { text: { fill: "#D1D5DB" } },
|
||||
labels: { text: { fill: chartPalette.text } },
|
||||
};
|
||||
|
||||
export function SankeyWidget({
|
||||
|
||||
@@ -1,27 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { ResponsiveBar } from "@nivo/bar";
|
||||
import { chartPalette } from "../../lib/chart-theme";
|
||||
import type { WaterfallStep } from "./types";
|
||||
|
||||
const barTheme = {
|
||||
background: "transparent",
|
||||
text: { fill: "#9CA3AF", fontSize: 11 },
|
||||
text: { fill: chartPalette.textMuted, fontSize: 11 },
|
||||
axis: {
|
||||
domain: { line: { stroke: "#374151" } },
|
||||
ticks: { line: { stroke: "#374151" }, text: { fill: "#9CA3AF" } },
|
||||
legend: { text: { fill: "#9CA3AF" } },
|
||||
domain: { line: { stroke: chartPalette.grid } },
|
||||
ticks: { line: { stroke: chartPalette.grid }, text: { fill: chartPalette.textMuted } },
|
||||
legend: { text: { fill: chartPalette.textMuted } },
|
||||
},
|
||||
grid: { line: { stroke: "#2D2D3A" } },
|
||||
grid: { line: { stroke: chartPalette.grid } },
|
||||
tooltip: {
|
||||
container: {
|
||||
background: "#1A1A26",
|
||||
color: "#E5E7EB",
|
||||
background: chartPalette.canvas,
|
||||
color: chartPalette.text,
|
||||
fontSize: 12,
|
||||
border: "1px solid #374151",
|
||||
border: `1px solid ${chartPalette.grid}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function formatCompactNumber(value: number): string {
|
||||
const abs = Math.abs(value);
|
||||
if (abs >= 1e9) {
|
||||
return `${(value / 1e9).toLocaleString(undefined, { maximumFractionDigits: 1 })}B`;
|
||||
}
|
||||
if (abs >= 1e6) {
|
||||
return `${(value / 1e6).toLocaleString(undefined, { maximumFractionDigits: 0 })}M`;
|
||||
}
|
||||
if (abs >= 1e3) {
|
||||
return `${(value / 1e3).toLocaleString(undefined, { maximumFractionDigits: 0 })}K`;
|
||||
}
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
function niceStep(rawStep: number): number {
|
||||
if (!Number.isFinite(rawStep) || rawStep <= 0) return 1;
|
||||
const exponent = Math.floor(Math.log10(rawStep));
|
||||
const fraction = rawStep / 10 ** exponent;
|
||||
|
||||
if (fraction <= 1) return 10 ** exponent;
|
||||
if (fraction <= 2) return 2 * 10 ** exponent;
|
||||
if (fraction <= 5) return 5 * 10 ** exponent;
|
||||
return 10 * 10 ** exponent;
|
||||
}
|
||||
|
||||
function buildTickValues(values: number[], maxTicks = 5): number[] {
|
||||
if (!values.length) return [0];
|
||||
|
||||
const min = Math.min(...values, 0);
|
||||
const max = Math.max(...values, 0);
|
||||
const span = max - min;
|
||||
|
||||
if (span === 0) {
|
||||
return [min];
|
||||
}
|
||||
|
||||
const step = niceStep(span / Math.max(1, maxTicks - 1));
|
||||
const start = Math.floor(min / step) * step;
|
||||
const end = Math.ceil(max / step) * step;
|
||||
const ticks: number[] = [];
|
||||
|
||||
for (let current = start; current <= end + step / 2; current += step) {
|
||||
ticks.push(Number(current.toFixed(6)));
|
||||
}
|
||||
|
||||
return ticks.slice(0, maxTicks + 1);
|
||||
}
|
||||
|
||||
export function WaterfallWidget({ steps }: { steps: WaterfallStep[] }) {
|
||||
if (!steps.length) {
|
||||
return (
|
||||
@@ -37,6 +86,7 @@ export function WaterfallWidget({ steps }: { steps: WaterfallStep[] }) {
|
||||
delta: s.value,
|
||||
type: s.step_type,
|
||||
}));
|
||||
const tickValues = buildTickValues(data.map((d) => d.delta));
|
||||
|
||||
return (
|
||||
<div className="w-full h-[min(380px,50vh)] min-h-[240px]">
|
||||
@@ -45,32 +95,26 @@ export function WaterfallWidget({ steps }: { steps: WaterfallStep[] }) {
|
||||
keys={["delta"]}
|
||||
indexBy="label"
|
||||
layout="horizontal"
|
||||
margin={{ top: 8, right: 28, bottom: 40, left: 160 }}
|
||||
margin={{ top: 8, right: 28, bottom: 56, left: 160 }}
|
||||
padding={0.35}
|
||||
valueScale={{ type: "linear" }}
|
||||
indexScale={{ type: "band", round: true }}
|
||||
colors={({ data: row }) => {
|
||||
const r = row as { type?: string; delta?: number };
|
||||
if (r.type === "total") return "#6366F1";
|
||||
return (r.delta ?? 0) >= 0 ? "#00D4AA" : "#F87171";
|
||||
if (r.type === "total") return chartPalette.navy;
|
||||
return (r.delta ?? 0) >= 0 ? chartPalette.green : chartPalette.red;
|
||||
}}
|
||||
borderRadius={2}
|
||||
axisTop={null}
|
||||
axisRight={null}
|
||||
axisBottom={{
|
||||
tickSize: 0,
|
||||
tickPadding: 8,
|
||||
tickPadding: 6,
|
||||
tickValues,
|
||||
legend: "USD (reported units)",
|
||||
legendPosition: "middle",
|
||||
legendOffset: 32,
|
||||
format: (v) => {
|
||||
const n = Number(v);
|
||||
const abs = Math.abs(n);
|
||||
if (abs >= 1e9) return `${(n / 1e9).toLocaleString(undefined, { maximumFractionDigits: 1 })}B`;
|
||||
if (abs >= 1e6) return `${(n / 1e6).toLocaleString(undefined, { maximumFractionDigits: 0 })}M`;
|
||||
if (abs >= 1e3) return `${(n / 1e3).toLocaleString(undefined, { maximumFractionDigits: 0 })}K`;
|
||||
return n.toLocaleString();
|
||||
},
|
||||
legendOffset: 44,
|
||||
format: (v) => formatCompactNumber(Number(v)),
|
||||
}}
|
||||
axisLeft={{
|
||||
tickSize: 0,
|
||||
@@ -87,7 +131,7 @@ export function WaterfallWidget({ steps }: { steps: WaterfallStep[] }) {
|
||||
}}
|
||||
labelSkipWidth={12}
|
||||
labelSkipHeight={12}
|
||||
labelTextColor="#E5E7EB"
|
||||
labelTextColor={chartPalette.text}
|
||||
theme={barTheme}
|
||||
tooltip={({ value, indexValue }) => (
|
||||
<div className="px-2 py-1 text-xs">
|
||||
|
||||
@@ -1,100 +1,97 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { normalizeTickerInput } from "../lib/ticker-alias";
|
||||
import { BarChart3, Briefcase, CalendarRange, FileSearch, FileText, Globe, Landmark, LineChart, Microscope, Newspaper, Search, Settings, Sparkles, Target, TrendingUp } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTicker } from "../lib/use-ticker";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/", label: "Overview", icon: "📊" },
|
||||
{ href: "/research", label: "Research", icon: "🔬" },
|
||||
{ href: "/valuation", label: "Valuation", icon: "💰" },
|
||||
{ href: "/technical", label: "Technical", icon: "📈" },
|
||||
{ href: "/markets", label: "Markets", icon: "🌍" },
|
||||
{ href: "/macro", label: "Macro", icon: "🌐" },
|
||||
{ href: "/earnings", label: "Earnings", icon: "📅" },
|
||||
{ href: "/news", label: "News", icon: "📰" },
|
||||
{ href: "/screener", label: "Screener", icon: "🎯" },
|
||||
{ href: "/portfolio", label: "Portfolio", icon: "💼" },
|
||||
{ href: "/filings", label: "Filings", icon: "📑" },
|
||||
{ href: "/report", label: "Report", icon: "🏦" },
|
||||
{ href: "/", label: "Overview", icon: BarChart3 },
|
||||
{ href: "/research", label: "Research", icon: Microscope },
|
||||
{ href: "/valuation", label: "Valuation", icon: Landmark },
|
||||
{ href: "/technical", label: "Technical", icon: TrendingUp },
|
||||
{ href: "/markets", label: "Markets", icon: Globe },
|
||||
{ href: "/macro", label: "Macro", icon: LineChart },
|
||||
{ href: "/earnings", label: "Earnings", icon: CalendarRange },
|
||||
{ href: "/news", label: "News", icon: Newspaper },
|
||||
{ href: "/screener", label: "Screener", icon: Target },
|
||||
{ href: "/portfolio", label: "Portfolio", icon: Briefcase },
|
||||
{ href: "/filings", label: "Filings", icon: FileSearch },
|
||||
{ href: "/report", label: "Report", icon: FileText },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const [input, setInput] = useState("");
|
||||
const [ticker, setTickerLocal] = useState("AAPL");
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("atlas_active_ticker");
|
||||
if (saved) setTickerLocal(saved);
|
||||
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (detail) setTickerLocal(detail);
|
||||
};
|
||||
window.addEventListener("atlas-ticker-change", handler);
|
||||
return () => window.removeEventListener("atlas-ticker-change", handler);
|
||||
}, []);
|
||||
const { ticker, setTicker } = useTicker();
|
||||
|
||||
function handleSearch() {
|
||||
const val = normalizeTickerInput(input);
|
||||
const val = input.trim();
|
||||
if (val) {
|
||||
setTickerLocal(val);
|
||||
localStorage.setItem("atlas_active_ticker", val);
|
||||
window.dispatchEvent(new CustomEvent("atlas-ticker-change", { detail: val }));
|
||||
setTicker(val);
|
||||
setInput("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="w-[260px] bg-bg-primary border-r border-border p-4 flex flex-col gap-2 fixed top-[52px] bottom-0 left-0 overflow-y-auto z-40">
|
||||
{/* Ticker Search */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 bg-bg-card border border-border rounded-lg px-3.5 py-2.5">
|
||||
<span className="text-text-muted">🔍</span>
|
||||
<aside className="fixed bottom-0 left-0 top-[56px] z-40 flex w-[260px] flex-col gap-2 overflow-y-auto border-r border-border bg-surface-raised px-4 py-5 shadow-card">
|
||||
<div className="rounded-md border border-border bg-surface-raised p-3 shadow-card">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-text-muted">
|
||||
Active Terminal
|
||||
</div>
|
||||
<div className="mt-1 font-serif text-lg font-bold text-brand-navy">ATLAS Desk</div>
|
||||
</div>
|
||||
<Sparkles className="h-4 w-4 text-brand-gold" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-surface-sunken px-3 py-2">
|
||||
<Search className="h-4 w-4 text-text-muted" />
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
placeholder="Search ticker..."
|
||||
className="bg-transparent border-none text-text-primary outline-none w-full"
|
||||
className="w-full border-none bg-transparent text-sm text-text-primary outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 px-3.5 py-1.5 bg-bg-card rounded-md flex items-center justify-between">
|
||||
<span className="text-text-muted text-sm">Active:</span>
|
||||
<span className="text-accent-green font-mono font-bold">{ticker}</span>
|
||||
<div className="mt-3 flex items-center justify-between rounded-md bg-brand-navy px-3 py-2 text-white">
|
||||
<span className="text-xs uppercase tracking-[0.12em] text-white/70">Ticker</span>
|
||||
<span className="font-mono text-sm font-bold text-brand-gold">{ticker}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex flex-col gap-1 mt-3">
|
||||
<nav className="mt-3 flex flex-col gap-1">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active = pathname === item.href;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link key={item.href} href={item.href} className="no-underline">
|
||||
<div
|
||||
className={`flex items-center gap-2.5 px-3.5 py-2.5 rounded-lg text-base transition-all duration-150 border-l-2 ${
|
||||
className={`flex items-center gap-3 rounded-md border-l-4 px-3.5 py-2.5 text-sm transition-all duration-150 ${
|
||||
active
|
||||
? "bg-accent-green/10 text-accent-green font-semibold border-accent-green"
|
||||
: "text-text-secondary font-normal border-transparent hover:bg-bg-card"
|
||||
? "border-brand-gold bg-surface-sunken font-semibold text-brand-navy"
|
||||
: "border-transparent text-text-secondary hover:bg-surface-sunken hover:text-brand-navy"
|
||||
}`}
|
||||
>
|
||||
<span>{item.icon}</span> {item.label}
|
||||
<Icon className={`h-[18px] w-[18px] ${active ? "text-brand-gold" : "text-brand-navy"}`} />
|
||||
{item.label}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="border-t border-border my-2" />
|
||||
<div className="my-2 border-t border-border" />
|
||||
<Link href="/settings" className="no-underline">
|
||||
<div
|
||||
className={`flex items-center gap-2.5 px-3.5 py-2.5 rounded-lg text-base transition-all duration-150 border-l-2 ${
|
||||
className={`flex items-center gap-3 rounded-md border-l-4 px-3.5 py-2.5 text-sm transition-all duration-150 ${
|
||||
pathname === "/settings"
|
||||
? "bg-accent-green/10 text-accent-green font-semibold border-accent-green"
|
||||
: "text-text-secondary font-normal border-transparent hover:bg-bg-card"
|
||||
? "border-brand-gold bg-surface-sunken font-semibold text-brand-navy"
|
||||
: "border-transparent text-text-secondary hover:bg-surface-sunken hover:text-brand-navy"
|
||||
}`}
|
||||
>
|
||||
<span>⚙️</span> Settings
|
||||
<Settings className={`h-[18px] w-[18px] ${pathname === "/settings" ? "text-brand-gold" : "text-brand-navy"}`} />
|
||||
Settings
|
||||
</div>
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Dot } from "lucide-react";
|
||||
|
||||
interface IndexData {
|
||||
label: string;
|
||||
@@ -41,17 +42,21 @@ export function TickerBar() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className="fixed top-0 left-0 right-0 z-50 h-[52px] bg-bg-primary border-b border-border flex items-center px-5 gap-4">
|
||||
<div className="font-mono font-bold text-accent-green text-lg mr-5">
|
||||
ATLAS<span className="text-text-secondary font-normal"> TERMINAL</span>
|
||||
<header className="fixed left-0 right-0 top-0 z-50 flex h-[56px] items-center gap-4 border-b border-border bg-surface-raised px-5 shadow-card">
|
||||
<div className="mr-4 flex items-baseline gap-2">
|
||||
<span className="font-serif text-xl font-bold text-brand-navy">ATLAS</span>
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-brand-gold">
|
||||
Morgan Terminal
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-5 overflow-hidden">
|
||||
{data.map((idx) => (
|
||||
<div key={idx.label} className="flex items-center gap-2 text-sm font-mono">
|
||||
<div key={idx.label} className="flex items-center gap-1.5 text-sm font-mono">
|
||||
<span className="text-text-muted">{idx.label}</span>
|
||||
<span className="text-text-primary font-semibold">{idx.price}</span>
|
||||
{idx.change !== "—" && (
|
||||
<span className={idx.positive ? "text-accent-green" : "text-accent-red"}>
|
||||
<span className={`inline-flex items-center ${idx.positive ? "text-fin-positive" : "text-fin-negative"}`}>
|
||||
<Dot className="-mx-1 h-4 w-4" />
|
||||
{idx.change}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface BadgeProps {
|
||||
variant: "buy" | "sell" | "hold" | "neutral";
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const badgeClasses: Record<BadgeProps["variant"], string> = {
|
||||
buy: "bg-fin-positive/10 text-fin-positive border-fin-positive/20",
|
||||
sell: "bg-fin-negative/10 text-fin-negative border-fin-negative/20",
|
||||
hold: "bg-brand-gold/15 text-brand-navy border-brand-gold/40",
|
||||
neutral: "bg-surface-sunken text-text-secondary border-border",
|
||||
};
|
||||
|
||||
export function Badge({ variant, children, className = "" }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-bold uppercase tracking-[0.08em] ${badgeClasses[variant]} ${className}`}
|
||||
>
|
||||
{children ?? variant}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface CardProps {
|
||||
title?: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
export function Card({
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
children,
|
||||
className = "",
|
||||
contentClassName = "",
|
||||
}: CardProps) {
|
||||
return (
|
||||
<section className={`atlas-card ${className}`}>
|
||||
{(title || subtitle || action) && (
|
||||
<header className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
{title && <h3 className="font-serif text-lg font-bold text-brand-navy">{title}</h3>}
|
||||
{subtitle && <p className="mt-1 text-sm text-text-secondary">{subtitle}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</header>
|
||||
)}
|
||||
<div className={`p-5 ${contentClassName}`}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface ChartContainerProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChartContainer({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
className = "",
|
||||
}: ChartContainerProps) {
|
||||
return (
|
||||
<section className={`atlas-card p-5 ${className}`}>
|
||||
{(title || subtitle) && (
|
||||
<div className="mb-4">
|
||||
{title && <h3 className="font-serif text-lg font-bold text-brand-navy">{title}</h3>}
|
||||
{subtitle && <p className="mt-1 text-sm text-text-secondary">{subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface Column<T> {
|
||||
key: string;
|
||||
header: ReactNode;
|
||||
align?: "left" | "right" | "center";
|
||||
render: (row: T) => ReactNode;
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
columns: Column<T>[];
|
||||
rows: T[];
|
||||
rowKey: (row: T, index: number) => string;
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const alignClass = {
|
||||
left: "text-left",
|
||||
right: "text-right",
|
||||
center: "text-center",
|
||||
} as const;
|
||||
|
||||
export function DataTable<T>({
|
||||
columns,
|
||||
rows,
|
||||
rowKey,
|
||||
emptyMessage = "No rows to display.",
|
||||
className = "",
|
||||
}: DataTableProps<T>) {
|
||||
return (
|
||||
<div className={`atlas-table-shell ${className}`}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full border-collapse text-sm">
|
||||
<thead className="bg-surface-sunken">
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
className={`border-b border-border-strong px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.12em] text-brand-navy ${alignClass[column.align ?? "left"]}`}
|
||||
>
|
||||
{column.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-4 py-6 text-center text-text-muted">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row, index) => (
|
||||
<tr key={rowKey(row, index)} className="border-b border-border last:border-b-0">
|
||||
{columns.map((column) => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={`px-4 py-3 align-top text-text-primary ${alignClass[column.align ?? "left"]}`}
|
||||
>
|
||||
{column.render(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { AlertTriangle, Info, TriangleAlert } from "lucide-react";
|
||||
|
||||
export type BannerVariant = "error" | "info" | "warning";
|
||||
|
||||
interface ErrorBannerProps {
|
||||
message: string | null | undefined;
|
||||
variant?: BannerVariant;
|
||||
className?: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified info / warning / error banner used across ATLAS pages.
|
||||
*
|
||||
* Design contract (preserved through the upcoming Morgan Stanley Blue redesign):
|
||||
* - "info" / "warning": soft yellow border+background, never red.
|
||||
* Used when data is partially missing or a dependency is degraded.
|
||||
* - "error": red. Reserved for true failures the user must act on.
|
||||
*
|
||||
* If `message` is falsy, renders nothing (drop-in safe).
|
||||
*/
|
||||
export function ErrorBanner({ message, variant = "info", className = "", icon }: ErrorBannerProps) {
|
||||
if (!message) return null;
|
||||
const base = "rounded-md border px-4 py-3 text-sm shadow-card";
|
||||
const toneMap: Record<BannerVariant, string> = {
|
||||
info: "border-brand-gold/40 bg-brand-gold/10 text-brand-navy",
|
||||
warning: "border-fin-warning/40 bg-fin-warning/10 text-fin-warning",
|
||||
error: "border-fin-negative/40 bg-fin-negative/10 text-fin-negative",
|
||||
};
|
||||
const defaultIcon =
|
||||
variant === "error" ? <AlertTriangle className="h-4 w-4" /> :
|
||||
variant === "warning" ? <TriangleAlert className="h-4 w-4" /> :
|
||||
<Info className="h-4 w-4" />;
|
||||
return (
|
||||
<div
|
||||
className={`${base} ${toneMap[variant]} ${className}`}
|
||||
role={variant === "error" ? "alert" : "status"}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className="mt-0.5 shrink-0">{icon ?? defaultIcon}</span>
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
|
||||
interface LoadingPulseProps {
|
||||
label?: string;
|
||||
height?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingPulse({ label = "Loading…", height = "h-64", className = "" }: LoadingPulseProps) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center ${height} ${className}`}>
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-border bg-surface-raised px-4 py-2 text-sm text-brand-navy shadow-card">
|
||||
<LoaderCircle className="h-4 w-4 animate-spin text-brand-blue" />
|
||||
<span className="font-mono">{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
interface MetricChangeProps {
|
||||
value: number | null;
|
||||
suffix?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MetricChange({ value, suffix = "%", className = "" }: MetricChangeProps) {
|
||||
if (value == null || Number.isNaN(value)) {
|
||||
return <span className={`font-mono text-xs text-text-muted ${className}`}>N/A</span>;
|
||||
}
|
||||
|
||||
const tone = value > 0 ? "text-fin-positive" : value < 0 ? "text-fin-negative" : "text-fin-neutral";
|
||||
return (
|
||||
<span className={`font-mono text-xs font-semibold ${tone} ${className}`}>
|
||||
{value > 0 ? "+" : ""}
|
||||
{value.toFixed(1)}
|
||||
{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface SectionHeadingProps {
|
||||
level?: 1 | 2 | 3;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionHeading({
|
||||
level = 2,
|
||||
children,
|
||||
className = "",
|
||||
}: SectionHeadingProps) {
|
||||
if (level === 1) {
|
||||
return <h1 className={`atlas-page-title ${className}`}>{children}</h1>;
|
||||
}
|
||||
|
||||
if (level === 3) {
|
||||
return (
|
||||
<h3 className={`text-xs font-semibold uppercase tracking-[0.18em] text-text-secondary ${className}`}>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
return <h2 className={`atlas-section-title ${className}`}>{children}</h2>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
detail?: ReactNode;
|
||||
tone?: "default" | "positive" | "negative" | "accent";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const toneClass: Record<NonNullable<StatCardProps["tone"]>, string> = {
|
||||
default: "text-text-primary",
|
||||
positive: "text-fin-positive",
|
||||
negative: "text-fin-negative",
|
||||
accent: "text-brand-blue",
|
||||
};
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
tone = "default",
|
||||
className = "",
|
||||
}: StatCardProps) {
|
||||
return (
|
||||
<div className={`atlas-card p-4 ${className}`}>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-text-muted">{label}</div>
|
||||
<div className={`mt-2 font-mono text-[22px] font-bold leading-tight ${toneClass[tone]}`}>{value}</div>
|
||||
{detail && <div className="mt-2 text-xs text-text-secondary">{detail}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface TabItem<T extends string> {
|
||||
key: T;
|
||||
label: ReactNode;
|
||||
}
|
||||
|
||||
interface TabsProps<T extends string> {
|
||||
items: TabItem<T>[];
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Tabs<T extends string>({ items, value, onChange, className = "" }: TabsProps<T>) {
|
||||
return (
|
||||
<div className={`flex gap-1 rounded-md border border-border bg-surface-sunken p-1 ${className}`}>
|
||||
{items.map((item) => {
|
||||
const active = item.key === value;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => onChange(item.key)}
|
||||
className={`flex-1 rounded-md px-3 py-2 text-sm font-semibold transition-colors ${
|
||||
active
|
||||
? "bg-brand-navy text-text-inverse shadow-sm"
|
||||
: "text-text-secondary hover:bg-surface-raised hover:text-brand-navy"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# ATLAS Morgan Stanley Design System
|
||||
|
||||
## Core Palette
|
||||
- `surface.canvas`: `#FAFAFA`
|
||||
- `surface.raised`: `#FFFFFF`
|
||||
- `surface.sunken`: `#F1F3F6`
|
||||
- `brand.navy`: `#1B2A4A`
|
||||
- `brand.blue`: `#2E5B9A`
|
||||
- `brand.gold`: `#C4A35A`
|
||||
- `fin.positive`: `#2D8B5E`
|
||||
- `fin.negative`: `#C0392B`
|
||||
- `fin.warning`: `#D9822B`
|
||||
- `text.primary`: `#1A1A2E`
|
||||
- `text.secondary`: `#4A5568`
|
||||
- `text.muted`: `#6B7B8D`
|
||||
|
||||
## Typography
|
||||
- Headings: `Source Serif 4`
|
||||
- Body: `Inter`
|
||||
- Numbers, tickers, dense tables: `JetBrains Mono` with tabular numerals
|
||||
|
||||
## Component Rules
|
||||
- Page titles use serif typography and a bottom hairline.
|
||||
- Cards use `surface.raised`, `border.DEFAULT`, and `shadow-card`.
|
||||
- Tabs use navy active states and sunken inactive rails.
|
||||
- Positive/negative metrics use `fin.positive` and `fin.negative`; warnings use gold or warning amber, not red.
|
||||
- Charts should import colors from `src/app/lib/chart-theme.ts` instead of hardcoding hex values.
|
||||
|
||||
## Migration Notes
|
||||
- Legacy `bg.*` and `accent.*` Tailwind tokens still exist as compatibility aliases.
|
||||
- `/report` remains the visual source of truth for print layout; the rest of the app is converging toward that tone.
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Info } from "lucide-react";
|
||||
import {
|
||||
FilingsViewer,
|
||||
type FilingsViewerHandle,
|
||||
@@ -330,8 +331,9 @@ export default function FilingsPage() {
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mt-3 bg-accent-red/10 border border-accent-red/30 rounded-md px-4 py-2.5 text-accent-red text-sm">
|
||||
{error}
|
||||
<div className="mt-3 flex items-start gap-2 rounded-md border border-accent-yellow/30 bg-accent-yellow/10 px-4 py-2.5 text-sm text-accent-yellow">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
{loading && (
|
||||
|
||||
@@ -2,6 +2,25 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--surface-canvas: #fafafa;
|
||||
--surface-raised: #ffffff;
|
||||
--surface-sunken: #f1f3f6;
|
||||
--surface-overlay: #ffffff;
|
||||
--brand-navy: #1b2a4a;
|
||||
--brand-blue: #2e5b9a;
|
||||
--brand-gold: #c4a35a;
|
||||
--text-primary: #1a1a2e;
|
||||
--text-secondary: #4a5568;
|
||||
--text-muted: #6b7b8d;
|
||||
--border-color: #e8ecf0;
|
||||
--border-strong: #cbd5df;
|
||||
--border-subtle: #f1f3f6;
|
||||
--fin-positive: #2d8b5e;
|
||||
--fin-negative: #c0392b;
|
||||
--fin-warning: #d9822b;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
@@ -13,40 +32,53 @@ html {
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: var(--surface-canvas);
|
||||
}
|
||||
|
||||
body {
|
||||
background: #0A0A0F;
|
||||
color: #F3F4F6;
|
||||
font-family: "Inter", system-ui, sans-serif;
|
||||
background: radial-gradient(circle at top left, rgba(196, 163, 90, 0.08), transparent 28%),
|
||||
linear-gradient(180deg, #fcfcfd 0%, var(--surface-canvas) 100%);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans), system-ui, sans-serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
--text-primary: #f3f4f6;
|
||||
--text-secondary: #9ca3af;
|
||||
--border-color: #2a2a3a;
|
||||
--bg-card: #1a1a26;
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--font-serif), Georgia, serif;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(46, 91, 154, 0.18);
|
||||
color: var(--brand-navy);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.font-mono,
|
||||
.tabular-nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
th,
|
||||
thead {
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.sec-viewer-container {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.65;
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.sec-viewer-container [id^="sec-item-"] {
|
||||
scroll-margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.sec-viewer-container,
|
||||
.sec-viewer-container * {
|
||||
background-color: transparent !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.sec-viewer-container a {
|
||||
color: #4da6ff !important;
|
||||
color: var(--brand-blue) !important;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -54,6 +86,7 @@ body {
|
||||
border-collapse: collapse;
|
||||
width: max-content;
|
||||
max-width: none;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.sec-viewer-container th,
|
||||
@@ -61,6 +94,7 @@ body {
|
||||
border: 1px solid var(--border-color) !important;
|
||||
padding: 0.35rem 0.5rem !important;
|
||||
vertical-align: top;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.sec-viewer-container img {
|
||||
@@ -69,144 +103,128 @@ body {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #0A0A0F;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #2A2A3A;
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #3A3A4A;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: #6B7280;
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c5ced8;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ba9bb;
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--brand-navy);
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--surface-sunken);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.edit-input {
|
||||
width: 100%;
|
||||
max-width: 120px;
|
||||
background: #0A0A0F;
|
||||
border: 1px solid #4DA6FF;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--brand-blue);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
color: #F3F4F6;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono), monospace;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.delete-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
inset: 0;
|
||||
background: rgba(27, 42, 74, 0.24);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.portfolio-header {
|
||||
.portfolio-header,
|
||||
.heatmap-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.currency-toggle {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: #1A1A26;
|
||||
border: 1px solid #2A2A3A;
|
||||
border-radius: 8px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.currency-btn {
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #9CA3AF;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.currency-btn:hover {
|
||||
color: #F3F4F6;
|
||||
background: #252536;
|
||||
}
|
||||
|
||||
.currency-btn.active {
|
||||
background: #00D4AA;
|
||||
color: #0A0A0F;
|
||||
}
|
||||
|
||||
.heatmap-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.currency-toggle,
|
||||
.index-toggle {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: #1A1A26;
|
||||
border: 1px solid #2A2A3A;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.currency-btn,
|
||||
.index-btn {
|
||||
padding: 6px 16px;
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #9CA3AF;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono), monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.index-btn {
|
||||
padding-inline: 16px;
|
||||
font-family: var(--font-sans), sans-serif;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.currency-btn:hover,
|
||||
.index-btn:hover {
|
||||
color: #F3F4F6;
|
||||
background: #252536;
|
||||
color: var(--brand-navy);
|
||||
background: rgba(27, 42, 74, 0.06);
|
||||
}
|
||||
|
||||
.currency-btn.active,
|
||||
.index-btn.active {
|
||||
background: #00D4AA;
|
||||
color: #0A0A0F;
|
||||
background: var(--brand-navy);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.treemap-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
min-height: 400px;
|
||||
background: #0A0A0F;
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.treemap-sector {
|
||||
@@ -218,15 +236,15 @@ input::placeholder {
|
||||
|
||||
.treemap-sector-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #9CA3AF;
|
||||
font-weight: 700;
|
||||
color: var(--brand-navy);
|
||||
padding: 4px 6px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
border-radius: 4px 0 4px 0;
|
||||
border-radius: 6px 0 6px 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -246,34 +264,37 @@ input::placeholder {
|
||||
min-height: 50px;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
transition: filter 0.15s, transform 0.1s;
|
||||
transition: filter 0.15s ease, transform 0.1s ease;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.treemap-cell:hover {
|
||||
filter: brightness(1.15);
|
||||
transform: scale(1.02);
|
||||
filter: brightness(1.04);
|
||||
transform: scale(1.01);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.treemap-ticker,
|
||||
.treemap-change,
|
||||
.treemap-subticker {
|
||||
font-family: var(--font-mono), monospace;
|
||||
}
|
||||
|
||||
.treemap-ticker {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #F3F4F6;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.treemap-change {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #F3F4F6;
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
.treemap-subticker {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 9px;
|
||||
color: #9CA3AF;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.heatmap-loading {
|
||||
@@ -281,6 +302,32 @@ input::placeholder {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
color: #9CA3AF;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.atlas-page {
|
||||
@apply space-y-6;
|
||||
}
|
||||
|
||||
.atlas-page-title {
|
||||
@apply border-b border-border pb-3 font-serif text-[32px] font-bold leading-tight text-brand-navy;
|
||||
}
|
||||
|
||||
.atlas-page-subtitle {
|
||||
@apply mt-2 text-sm text-text-secondary;
|
||||
}
|
||||
|
||||
.atlas-card {
|
||||
@apply rounded-md border border-border bg-surface-raised shadow-card;
|
||||
}
|
||||
|
||||
.atlas-table-shell {
|
||||
@apply overflow-hidden rounded-md border border-border bg-surface-raised shadow-card;
|
||||
}
|
||||
|
||||
.atlas-section-title {
|
||||
@apply border-b border-border pb-2 font-serif text-[22px] font-bold leading-snug text-brand-navy;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter, JetBrains_Mono, Source_Serif_4 } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { AppShell } from "./components/app-shell";
|
||||
|
||||
@@ -7,17 +8,31 @@ export const metadata: Metadata = {
|
||||
description: "Advanced Trading & Liquidity Analysis System",
|
||||
};
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const sourceSerif = Source_Serif_4({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-serif",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const jetBrainsMono = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-mono",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-screen antialiased" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${inter.variable} ${sourceSerif.variable} ${jetBrainsMono.variable} min-h-screen antialiased`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<AppShell>{children}</AppShell>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
export const chartPalette = {
|
||||
navy: "#1B2A4A",
|
||||
blue: "#2E5B9A",
|
||||
gold: "#C4A35A",
|
||||
green: "#2D8B5E",
|
||||
red: "#C0392B",
|
||||
neutral: "#6B7B8D",
|
||||
neutralLight: "#CBD5DF",
|
||||
grid: "#E8ECF0",
|
||||
canvas: "#FFFFFF",
|
||||
sunken: "#F1F3F6",
|
||||
text: "#1A1A2E",
|
||||
textMuted: "#6B7B8D",
|
||||
} as const;
|
||||
|
||||
export const categoricalChartColors = [
|
||||
chartPalette.navy,
|
||||
chartPalette.blue,
|
||||
chartPalette.gold,
|
||||
chartPalette.green,
|
||||
chartPalette.red,
|
||||
chartPalette.neutral,
|
||||
] as const;
|
||||
|
||||
export const rechartsTheme = {
|
||||
gridStroke: chartPalette.grid,
|
||||
axisStroke: chartPalette.neutralLight,
|
||||
tickFill: chartPalette.textMuted,
|
||||
tooltip: {
|
||||
backgroundColor: chartPalette.canvas,
|
||||
border: `1px solid ${chartPalette.grid}`,
|
||||
color: chartPalette.text,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const lightweightTheme = {
|
||||
layout: {
|
||||
background: { color: chartPalette.canvas },
|
||||
textColor: chartPalette.textMuted,
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: chartPalette.grid },
|
||||
horzLines: { color: chartPalette.grid },
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: chartPalette.neutralLight,
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: chartPalette.neutralLight,
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* useApi — A lightweight fetch hook for ATLAS Terminal.
|
||||
*
|
||||
* Features:
|
||||
* - Hydration-safe (doesn't fire on the server).
|
||||
* - Automatic AbortController management across deps changes / unmount.
|
||||
* - Module-scoped in-flight request dedup: same URL + same method share a promise.
|
||||
* - Parses `{ detail }` and `{ error }` backend error shapes.
|
||||
* - Conditional fetch via `opts.enabled` or passing `url = null`.
|
||||
* - Manual `refetch()` escape hatch.
|
||||
*
|
||||
* We intentionally do NOT cache responses. A future SWR migration can layer caching
|
||||
* on top without changing the hook surface.
|
||||
*/
|
||||
|
||||
type Json = unknown;
|
||||
|
||||
interface InFlightEntry {
|
||||
promise: Promise<Response>;
|
||||
controller: AbortController;
|
||||
refCount: number;
|
||||
}
|
||||
|
||||
const inflight = new Map<string, InFlightEntry>();
|
||||
|
||||
function makeKey(url: string, method: string, body?: string): string {
|
||||
return `${method.toUpperCase()} ${url}${body ? ` :: ${body}` : ""}`;
|
||||
}
|
||||
|
||||
async function sharedFetch(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
externalSignal: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const method = (init.method || "GET").toUpperCase();
|
||||
const bodyKey = typeof init.body === "string" ? init.body : undefined;
|
||||
const key = makeKey(url, method, bodyKey);
|
||||
|
||||
let entry = inflight.get(key);
|
||||
if (!entry) {
|
||||
const controller = new AbortController();
|
||||
const promise = fetch(url, { ...init, signal: controller.signal }).finally(() => {
|
||||
inflight.delete(key);
|
||||
});
|
||||
entry = { promise, controller, refCount: 0 };
|
||||
inflight.set(key, entry);
|
||||
}
|
||||
entry.refCount += 1;
|
||||
|
||||
const onAbort = () => {
|
||||
if (!entry) return;
|
||||
entry.refCount -= 1;
|
||||
// Only abort the underlying request if no other caller still needs it.
|
||||
if (entry.refCount <= 0) {
|
||||
entry.controller.abort();
|
||||
inflight.delete(key);
|
||||
}
|
||||
};
|
||||
if (externalSignal.aborted) {
|
||||
onAbort();
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
externalSignal.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
try {
|
||||
// Clone so multiple consumers can each call .json() on the response.
|
||||
const resp = await entry.promise;
|
||||
return resp.clone();
|
||||
} finally {
|
||||
externalSignal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
async function parseError(resp: Response): Promise<string> {
|
||||
try {
|
||||
const data = await resp.clone().json();
|
||||
if (data && typeof data === "object") {
|
||||
const obj = data as Record<string, unknown>;
|
||||
if (typeof obj.detail === "string") return obj.detail;
|
||||
if (typeof obj.error === "string") return obj.error;
|
||||
if (typeof obj.message === "string") return obj.message;
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
try {
|
||||
const txt = await resp.text();
|
||||
if (txt) return txt.slice(0, 240);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return `HTTP ${resp.status} ${resp.statusText || ""}`.trim();
|
||||
}
|
||||
|
||||
export interface UseApiOptions {
|
||||
enabled?: boolean;
|
||||
method?: "GET" | "POST";
|
||||
body?: Json;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface UseApiResult<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useApi<T = unknown>(
|
||||
url: string | null,
|
||||
opts: UseApiOptions = {},
|
||||
): UseApiResult<T> {
|
||||
const { enabled = true, method = "GET", body, headers } = opts;
|
||||
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(Boolean(url) && enabled !== false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tick, setTick] = useState(0);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const bodyStr = body !== undefined ? JSON.stringify(body) : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!url || enabled === false) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
...(bodyStr ? { "Content-Type": "application/json" } : {}),
|
||||
...(headers || {}),
|
||||
},
|
||||
body: bodyStr,
|
||||
};
|
||||
|
||||
sharedFetch(url, init, controller.signal)
|
||||
.then(async (resp) => {
|
||||
if (!resp.ok) {
|
||||
const msg = await parseError(resp);
|
||||
throw new Error(msg);
|
||||
}
|
||||
const ct = resp.headers.get("content-type") || "";
|
||||
const payload = ct.includes("application/json") ? await resp.json() : await resp.text();
|
||||
if (!mountedRef.current || controller.signal.aborted) return;
|
||||
setData(payload as T);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if ((err as Error)?.name === "AbortError") return;
|
||||
if (!mountedRef.current) return;
|
||||
setError((err as Error)?.message || "Request failed");
|
||||
setData(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!mountedRef.current || controller.signal.aborted) return;
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url, method, bodyStr, enabled, tick]);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
setTick((t) => t + 1);
|
||||
}, []);
|
||||
|
||||
return { data, loading, error, refetch };
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { EconomicCalendar } from "../components/markets/EconomicCalendar";
|
||||
import { GlobalMacroQuadrantChart, type QuadrantPoint } from "../components/macro/GlobalMacroQuadrantChart";
|
||||
import { YieldFxDualAxisChart, type YieldFxRow } from "../components/macro/YieldFxDualAxisChart";
|
||||
import { SmartMoneyPanel } from "../components/macro/SmartMoneyPanel";
|
||||
import { ErrorBanner } from "../components/ui/ErrorBanner";
|
||||
import { LoadingPulse } from "../components/ui/LoadingPulse";
|
||||
|
||||
const FRED_PRESETS = [
|
||||
{ id: "UNRATE", label: "US Unemployment %" },
|
||||
@@ -40,6 +42,30 @@ interface SubfactorData {
|
||||
categories: Record<string, { score: number; indicators: SubfactorIndicator[] }>;
|
||||
}
|
||||
|
||||
const MACRO_ERROR_MESSAGES: Record<string, string> = {
|
||||
upstream_timeout: "Upstream macro source timed out. Try again in a moment.",
|
||||
fred_empty: "FRED returned no usable series for this view.",
|
||||
fx_empty: "FX history could not be loaded for this pair.",
|
||||
invalid_pair: "This yield/FX pair is not supported.",
|
||||
missing_peer: "Peer bond series is unavailable for this pair.",
|
||||
};
|
||||
|
||||
function formatMacroError(error: unknown, fallback: string): string {
|
||||
if (typeof error !== "string" || !error.trim()) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (error in MACRO_ERROR_MESSAGES) {
|
||||
return MACRO_ERROR_MESSAGES[error];
|
||||
}
|
||||
|
||||
if (error.startsWith("HTTP_")) {
|
||||
return `Request failed (${error.replace("HTTP_", "HTTP ")}).`;
|
||||
}
|
||||
|
||||
return error.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
export default function MacroPage() {
|
||||
const [series, setSeries] = useState("UNRATE");
|
||||
const [rows, setRows] = useState<FredPoint[]>([]);
|
||||
@@ -55,10 +81,12 @@ export default function MacroPage() {
|
||||
const [subfactors, setSubfactors] = useState<SubfactorData | null>(null);
|
||||
|
||||
const [quadPoints, setQuadPoints] = useState<QuadrantPoint[]>([]);
|
||||
const [quadLoading, setQuadLoading] = useState(true);
|
||||
const [quadErr, setQuadErr] = useState<string | null>(null);
|
||||
|
||||
const [yieldPair, setYieldPair] = useState<"usdjpy" | "eurusd" | "usdkrw">("usdjpy");
|
||||
const [yieldRows, setYieldRows] = useState<YieldFxRow[]>([]);
|
||||
const [yieldLoading, setYieldLoading] = useState(true);
|
||||
const [yieldErr, setYieldErr] = useState<string | null>(null);
|
||||
|
||||
const [roroZ, setRoroZ] = useState<number | null>(null);
|
||||
@@ -66,43 +94,60 @@ export default function MacroPage() {
|
||||
const [copperGold, setCopperGold] = useState<
|
||||
{ date: string; ratio: number; ratio_ma20?: number }[]
|
||||
>([]);
|
||||
const [smartLoading, setSmartLoading] = useState(true);
|
||||
const [smartErr, setSmartErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
fetch(`/api/macro/fred/${encodeURIComponent(series)}`)
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
|
||||
const controller = new AbortController();
|
||||
fetch(`/api/macro/fred/${encodeURIComponent(series)}`, { signal: controller.signal })
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP_${r.status}`))))
|
||||
.then((j) => {
|
||||
setRows(Array.isArray(j.data) ? j.data : []);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setErr("Failed to load FRED series.");
|
||||
.catch((error: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setErr(formatMacroError(error instanceof Error ? error.message : error, "Failed to load FRED series."));
|
||||
setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [series]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/macro/snapshot")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
setSnapErr(null);
|
||||
const controller = new AbortController();
|
||||
fetch("/api/macro/snapshot", { signal: controller.signal })
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP_${r.status}`))))
|
||||
.then((j) => {
|
||||
if (!j || typeof j !== "object") {
|
||||
setSnap(null);
|
||||
setSnapErr("Macro snapshot returned an empty payload.");
|
||||
return;
|
||||
}
|
||||
if (j.error) setSnapErr(String(j.error));
|
||||
const cycleHeatmap = Array.isArray(j.cycle_heatmap) ? j.cycle_heatmap : [];
|
||||
const countryHeatmap = Array.isArray(j.country_heatmap) ? j.country_heatmap : [];
|
||||
const assetValuation = Array.isArray(j.asset_valuation) ? j.asset_valuation : [];
|
||||
const hasSnapshotData = cycleHeatmap.length > 0 || countryHeatmap.length > 0 || assetValuation.length > 0;
|
||||
if (j.error) setSnapErr(formatMacroError(j.error, "Failed to load macro snapshot."));
|
||||
else if (!hasSnapshotData) setSnapErr("Macro snapshot returned no usable data.");
|
||||
else setSnapErr(null);
|
||||
setSnap({
|
||||
updated_at: j.updated_at ?? null,
|
||||
cycle_score: typeof j.cycle_score === "number" ? j.cycle_score : 0,
|
||||
regime: String(j.regime ?? "—"),
|
||||
cycle_heatmap: Array.isArray(j.cycle_heatmap) ? j.cycle_heatmap : [],
|
||||
country_heatmap: Array.isArray(j.country_heatmap) ? j.country_heatmap : [],
|
||||
asset_valuation: Array.isArray(j.asset_valuation) ? j.asset_valuation : [],
|
||||
cycle_heatmap: cycleHeatmap,
|
||||
country_heatmap: countryHeatmap,
|
||||
asset_valuation: assetValuation,
|
||||
});
|
||||
})
|
||||
.catch(() => setSnap(null));
|
||||
.catch((error: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setSnap(null);
|
||||
setSnapErr(formatMacroError(error instanceof Error ? error.message : error, "Failed to load macro snapshot."));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -123,55 +168,100 @@ export default function MacroPage() {
|
||||
|
||||
useEffect(() => {
|
||||
setQuadErr(null);
|
||||
fetch("/api/macro/quadrant")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
setQuadLoading(true);
|
||||
const controller = new AbortController();
|
||||
fetch("/api/macro/quadrant", { signal: controller.signal })
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP_${r.status}`))))
|
||||
.then((j) => {
|
||||
if (!j || typeof j !== "object") {
|
||||
setQuadPoints([]);
|
||||
setQuadErr("Macro quadrant returned an empty payload.");
|
||||
return;
|
||||
}
|
||||
if (j.error) setQuadErr(String(j.error));
|
||||
setQuadPoints(Array.isArray(j.points) ? j.points : []);
|
||||
const points = Array.isArray(j.points) ? j.points : [];
|
||||
if (j.error) setQuadErr(formatMacroError(j.error, "Failed to load macro quadrant."));
|
||||
else if (!points.length) setQuadErr("No quadrant points were produced from current macro sources.");
|
||||
setQuadPoints(points);
|
||||
})
|
||||
.catch(() => setQuadPoints([]));
|
||||
.catch((error: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setQuadPoints([]);
|
||||
setQuadErr(formatMacroError(error instanceof Error ? error.message : error, "Failed to load macro quadrant."));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
setQuadLoading(false);
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setYieldErr(null);
|
||||
fetch(`/api/macro/yield-fx?pair=${encodeURIComponent(yieldPair)}`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
setYieldLoading(true);
|
||||
const controller = new AbortController();
|
||||
fetch(`/api/macro/yield-fx?pair=${encodeURIComponent(yieldPair)}`, { signal: controller.signal })
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP_${r.status}`))))
|
||||
.then((j) => {
|
||||
if (!j || typeof j !== "object") {
|
||||
setYieldRows([]);
|
||||
setYieldErr("Yield/FX returned an empty payload.");
|
||||
return;
|
||||
}
|
||||
if (j.error) setYieldErr(String(j.error));
|
||||
setYieldRows(Array.isArray(j.series) ? j.series : []);
|
||||
const seriesRows = Array.isArray(j.series) ? j.series : [];
|
||||
if (j.error) setYieldErr(formatMacroError(j.error, "Failed to load yield/FX data."));
|
||||
else if (!seriesRows.length) setYieldErr("Yield/FX returned no usable observations.");
|
||||
setYieldRows(seriesRows);
|
||||
})
|
||||
.catch(() => setYieldRows([]));
|
||||
.catch((error: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setYieldRows([]);
|
||||
setYieldErr(formatMacroError(error instanceof Error ? error.message : error, "Failed to load yield/FX data."));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
setYieldLoading(false);
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [yieldPair]);
|
||||
|
||||
useEffect(() => {
|
||||
setSmartErr(null);
|
||||
fetch("/api/macro/smart-money")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
setSmartLoading(true);
|
||||
const controller = new AbortController();
|
||||
fetch("/api/macro/smart-money", { signal: controller.signal })
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP_${r.status}`))))
|
||||
.then((j) => {
|
||||
if (!j || typeof j !== "object") {
|
||||
setRoroZ(null);
|
||||
setRoroLabel(null);
|
||||
setCopperGold([]);
|
||||
setSmartErr("Smart money returned an empty payload.");
|
||||
return;
|
||||
}
|
||||
if (j.error) setSmartErr(String(j.error));
|
||||
setRoroZ(typeof j.roro_z === "number" ? j.roro_z : null);
|
||||
setRoroLabel(j.roro_label != null ? String(j.roro_label) : null);
|
||||
setCopperGold(Array.isArray(j.copper_gold) ? j.copper_gold : []);
|
||||
const nextRoroZ = typeof j.roro_z === "number" ? j.roro_z : null;
|
||||
const nextRoroLabel = j.roro_label != null ? String(j.roro_label) : null;
|
||||
const nextCopperGold = Array.isArray(j.copper_gold) ? j.copper_gold : [];
|
||||
if (j.error) setSmartErr(formatMacroError(j.error, "Failed to load smart money data."));
|
||||
else if (nextRoroZ == null && !nextCopperGold.length) setSmartErr("Smart money inputs returned no usable data.");
|
||||
setRoroZ(nextRoroZ);
|
||||
setRoroLabel(nextRoroLabel);
|
||||
setCopperGold(nextCopperGold);
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((error: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setRoroZ(null);
|
||||
setRoroLabel(null);
|
||||
setCopperGold([]);
|
||||
setSmartErr(formatMacroError(error instanceof Error ? error.message : error, "Failed to load smart money data."));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
setSmartLoading(false);
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
const tabs: { key: MacroTab; label: string }[] = [
|
||||
@@ -198,10 +288,12 @@ export default function MacroPage() {
|
||||
<div className="lg:col-span-12 bg-bg-secondary/30 border border-border/60 rounded-lg p-4">
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-1">Global macro quadrant</h2>
|
||||
<p className="text-text-muted text-xs mb-3">3M momentum Z-scores (growth vs inflation).</p>
|
||||
{quadErr && (
|
||||
<div className="text-accent-yellow text-xs font-mono mb-2">Warning: {quadErr}</div>
|
||||
<ErrorBanner message={quadErr} variant="warning" className="mb-3" />
|
||||
{quadLoading ? (
|
||||
<LoadingPulse label="Loading macro quadrant…" height="h-[320px]" />
|
||||
) : (
|
||||
<GlobalMacroQuadrantChart points={quadPoints} />
|
||||
)}
|
||||
<GlobalMacroQuadrantChart points={quadPoints} />
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-6 bg-bg-secondary/30 border border-border/60 rounded-lg p-4">
|
||||
@@ -224,19 +316,23 @@ export default function MacroPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{yieldErr && (
|
||||
<div className="text-accent-yellow text-xs font-mono mb-2">Warning: {yieldErr}</div>
|
||||
<ErrorBanner message={yieldErr} variant="warning" className="mb-3" />
|
||||
{yieldLoading ? (
|
||||
<LoadingPulse label="Loading yield/FX series…" height="h-[280px]" />
|
||||
) : (
|
||||
<YieldFxDualAxisChart pair={yieldPair} rows={yieldRows} />
|
||||
)}
|
||||
<YieldFxDualAxisChart pair={yieldPair} rows={yieldRows} />
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-6 bg-bg-secondary/30 border border-border/60 rounded-lg p-4">
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-1">Smart money & RORO</h2>
|
||||
<p className="text-text-muted text-xs mb-3">HG/GC ratio and composite risk Z-score.</p>
|
||||
{smartErr && (
|
||||
<div className="text-accent-yellow text-xs font-mono mb-2">Warning: {smartErr}</div>
|
||||
<ErrorBanner message={smartErr} variant="warning" className="mb-3" />
|
||||
{smartLoading ? (
|
||||
<LoadingPulse label="Loading smart money indicators…" height="h-[260px]" />
|
||||
) : (
|
||||
<SmartMoneyPanel roroZ={roroZ} roroLabel={roroLabel} copperGold={copperGold} />
|
||||
)}
|
||||
<SmartMoneyPanel roroZ={roroZ} roroLabel={roroLabel} copperGold={copperGold} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -287,9 +383,9 @@ export default function MacroPage() {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-accent-green animate-pulse font-mono">Loading data...</div>
|
||||
<LoadingPulse label="Loading FRED series…" height="h-20" className="justify-start" />
|
||||
) : err ? (
|
||||
<div className="text-accent-red border border-accent-red/30 rounded-lg p-4">{err}</div>
|
||||
<ErrorBanner message={err} variant="error" />
|
||||
) : (
|
||||
<div className="bg-bg-secondary border border-border rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2 border-b border-border text-text-muted text-sm font-mono">
|
||||
@@ -377,11 +473,7 @@ export default function MacroPage() {
|
||||
|
||||
{tab === "cycle" && (
|
||||
<div className="space-y-4">
|
||||
{snapErr && (
|
||||
<div className="text-accent-yellow border border-accent-yellow/30 rounded-lg p-3 text-sm">
|
||||
Snapshot warning: {snapErr}
|
||||
</div>
|
||||
)}
|
||||
<ErrorBanner message={snapErr ? `Snapshot warning: ${snapErr}` : null} variant="warning" />
|
||||
<MacroCycleHeatmap data={snap} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,55 +1,57 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTicker } from "./lib/use-ticker";
|
||||
|
||||
import { CommodityOverview } from "./components/overview/CommodityOverview";
|
||||
import { EquityOverview } from "./components/overview/EquityOverview";
|
||||
import { ETFOverview } from "./components/overview/ETFOverview";
|
||||
import { CommodityOverview } from "./components/overview/CommodityOverview";
|
||||
import { ErrorBanner } from "./components/ui/ErrorBanner";
|
||||
import { LoadingPulse } from "./components/ui/LoadingPulse";
|
||||
import { SectionHeading } from "./components/ui/SectionHeading";
|
||||
import { useApi } from "./lib/use-api";
|
||||
import { useTicker } from "./lib/use-ticker";
|
||||
|
||||
interface OverviewResp {
|
||||
asset_type?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export default function OverviewPage() {
|
||||
const { ticker, initialized } = useTicker();
|
||||
const [sector, setSector] = useState<Record<string, unknown> | null>(null);
|
||||
const [health, setHealth] = useState<Record<string, unknown> | null>(null);
|
||||
const [overview, setOverview] = useState<Record<string, unknown> | null>(null);
|
||||
const [assetType, setAssetType] = useState<string>("equity");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized) return;
|
||||
const ac = new AbortController();
|
||||
setLoading(true);
|
||||
setSector(null);
|
||||
setHealth(null);
|
||||
setOverview(null);
|
||||
Promise.all([
|
||||
fetch(`/api/market/sector/${ticker}`, { signal: ac.signal }).then((r) => r.ok ? r.json() : null),
|
||||
fetch(`/api/market/health/${ticker}`, { signal: ac.signal }).then((r) => r.ok ? r.json() : null),
|
||||
fetch(`/api/market/overview/${ticker}`, { signal: ac.signal }).then((r) => r.ok ? r.json() : null),
|
||||
]).then(([s, h, d]) => {
|
||||
if (ac.signal.aborted) return;
|
||||
setSector(s);
|
||||
setHealth(h);
|
||||
setAssetType(d?.asset_type || "equity");
|
||||
setOverview(d?.data || null);
|
||||
setLoading(false);
|
||||
}).catch(() => { if (!ac.signal.aborted) setLoading(false); });
|
||||
return () => ac.abort();
|
||||
}, [ticker, initialized]);
|
||||
const sectorUrl = initialized ? `/api/market/sector/${ticker}` : null;
|
||||
const healthUrl = initialized ? `/api/market/health/${ticker}` : null;
|
||||
const overviewUrl = initialized ? `/api/market/overview/${ticker}` : null;
|
||||
|
||||
if (loading) return <LoadingState />;
|
||||
const sector = useApi<Record<string, unknown>>(sectorUrl);
|
||||
const health = useApi<Record<string, unknown>>(healthUrl);
|
||||
const overview = useApi<OverviewResp>(overviewUrl);
|
||||
|
||||
const loading = !initialized || sector.loading || health.loading || overview.loading;
|
||||
if (loading) return <LoadingPulse label="Loading data..." />;
|
||||
|
||||
// Surface only truly catastrophic failures as a banner — per-component empty
|
||||
// states already handle partial data gracefully.
|
||||
const fatalError =
|
||||
sector.error && health.error && overview.error
|
||||
? "Unable to reach the backend. Please start the API server or refresh."
|
||||
: null;
|
||||
|
||||
if (fatalError) {
|
||||
return (
|
||||
<div className="atlas-page">
|
||||
<SectionHeading level={1}>{ticker} Overview</SectionHeading>
|
||||
<ErrorBanner variant="error" message={fatalError} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const assetType = overview.data?.asset_type || "equity";
|
||||
const overviewData = overview.data?.data || {};
|
||||
|
||||
if (assetType === "etf") {
|
||||
return <ETFOverview ticker={ticker} data={overview || {}} />;
|
||||
return <ETFOverview ticker={ticker} data={overviewData} />;
|
||||
}
|
||||
if (assetType === "commodity_future") {
|
||||
return <CommodityOverview ticker={ticker} data={overview || {}} />;
|
||||
return <CommodityOverview ticker={ticker} data={overviewData} />;
|
||||
}
|
||||
return <EquityOverview ticker={ticker} sector={sector} health={health} />;
|
||||
}
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-accent-green animate-pulse font-mono">Loading data...</div>
|
||||
</div>
|
||||
);
|
||||
return <EquityOverview ticker={ticker} sector={sector.data} health={health.data} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import { AlertTriangle, Check, CheckCircle2, CircleX, Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
interface Position {
|
||||
id?: string;
|
||||
@@ -474,7 +475,13 @@ export default function PortfolioPage() {
|
||||
{p.pnl_pct != null ? `${p.pnl_pct >= 0 ? "+" : ""}${p.pnl_pct.toFixed(2)}%` : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{p.confidence === "high" ? "✅" : p.confidence === "medium" ? "⚠️" : "❌"}
|
||||
{p.confidence === "high" ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-fin-positive" />
|
||||
) : p.confidence === "medium" ? (
|
||||
<AlertTriangle className="h-4 w-4 text-fin-warning" />
|
||||
) : (
|
||||
<CircleX className="h-4 w-4 text-fin-negative" />
|
||||
)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
@@ -491,8 +498,9 @@ export default function PortfolioPage() {
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={importOcrPositions} className="bg-accent-green text-bg-primary px-5 py-2 rounded-md font-semibold hover:opacity-90 transition-opacity">
|
||||
✅ Add All to Portfolio
|
||||
<button onClick={importOcrPositions} className="inline-flex items-center gap-2 bg-accent-green text-bg-primary px-5 py-2 rounded-md font-semibold hover:opacity-90 transition-opacity">
|
||||
<Check className="h-4 w-4" />
|
||||
Add All to Portfolio
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -564,8 +572,12 @@ export default function PortfolioPage() {
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
{editingId === pid ? (
|
||||
<div className="flex gap-1 justify-end">
|
||||
<button onClick={() => handleSaveEdit(pid)} className="w-8 h-8 rounded bg-accent-green/20 hover:bg-accent-green/30">✓</button>
|
||||
<button onClick={() => setEditingId(null)} className="w-8 h-8 rounded bg-bg-primary hover:bg-bg-hover">✕</button>
|
||||
<button onClick={() => handleSaveEdit(pid)} className="flex h-8 w-8 items-center justify-center rounded bg-accent-green/20 hover:bg-accent-green/30">
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button onClick={() => setEditingId(null)} className="flex h-8 w-8 items-center justify-center rounded bg-bg-primary hover:bg-bg-hover">
|
||||
<CircleX className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-1 justify-end">
|
||||
@@ -574,17 +586,17 @@ export default function PortfolioPage() {
|
||||
setEditingId(pid);
|
||||
setEditValues({ qty: String(p.quantity), avgPrice: String(p.avg_price) });
|
||||
}}
|
||||
className="w-8 h-8 rounded hover:bg-bg-hover"
|
||||
className="flex h-8 w-8 items-center justify-center rounded hover:bg-bg-hover"
|
||||
title="Edit position"
|
||||
>
|
||||
✏️
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(pid)}
|
||||
className="w-8 h-8 rounded hover:bg-accent-red/20"
|
||||
className="flex h-8 w-8 items-center justify-center rounded hover:bg-accent-red/20"
|
||||
title="Delete position"
|
||||
>
|
||||
🗑️
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/**
|
||||
* Financial performance + quality / risk sections.
|
||||
* Extracted from the original /report monolith.
|
||||
*/
|
||||
import {
|
||||
BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid,
|
||||
Tooltip, ResponsiveContainer, Cell, Legend,
|
||||
} from "recharts";
|
||||
import { C } from "../design-tokens";
|
||||
import { getValue } from "../lib/formatters";
|
||||
import type { FinancialPeriod, ResearchDash, HealthData, StatementsBundle } from "../types";
|
||||
|
||||
export function FinancialCharts({ statements }: { statements: StatementsBundle }) {
|
||||
const is = statements.income_statement;
|
||||
if (!is || is.length === 0) return null;
|
||||
|
||||
const chartData = is.slice(0, 5).reverse().map((p: FinancialPeriod) => {
|
||||
const period = p as Record<string, unknown>;
|
||||
const rev = getValue(period, "TotalRevenue|Total Revenue|Revenue");
|
||||
const ni = getValue(period, "NetIncome|Net Income|Net Income Common Stockholders");
|
||||
const gp = getValue(period, "GrossProfit|Gross Profit");
|
||||
const op = getValue(period, "OperatingIncome|Operating Income");
|
||||
const yr = (period.asOfDate || period.fiscalYear || period.year || "") as string | number;
|
||||
return {
|
||||
year: typeof yr === "string" ? yr.slice(0, 4) : String(yr),
|
||||
revenue: rev ? rev / 1e9 : 0,
|
||||
netIncome: ni ? ni / 1e9 : 0,
|
||||
grossMargin: rev && gp ? (gp / rev) * 100 : 0,
|
||||
opMargin: rev && op ? (op / rev) * 100 : 0,
|
||||
netMargin: rev && ni ? (ni / rev) * 100 : 0,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="report-section">
|
||||
<h2 className="section-title">Income Statement Trends</h2>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className="chart-title">Revenue & Net Income ($B)</h3>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" />
|
||||
<XAxis dataKey="year" tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="revenue" fill={C.blue} name="Revenue" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="netIncome" fill={C.gold} name="Net Income" radius={[2, 2, 0, 0]} />
|
||||
<Legend wrapperStyle={{ fontSize: 10 }} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="chart-title">Margin Trends (%)</h3>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" />
|
||||
<XAxis dataKey="year" tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} />
|
||||
<Tooltip formatter={(value: number | string) => `${Number(value).toFixed(1)}%`} />
|
||||
<Line type="monotone" dataKey="grossMargin" stroke={C.green} name="Gross" strokeWidth={2} dot={{ r: 3 }} />
|
||||
<Line type="monotone" dataKey="opMargin" stroke={C.blue} name="Operating" strokeWidth={2} dot={{ r: 3 }} />
|
||||
<Line type="monotone" dataKey="netMargin" stroke={C.gold} name="Net" strokeWidth={2} dot={{ r: 3 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 10 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BalanceSheetCashFlow({ statements }: { statements: StatementsBundle }) {
|
||||
const bs = statements.balance_sheet;
|
||||
const cf = statements.cash_flow;
|
||||
if ((!bs || bs.length === 0) && (!cf || cf.length === 0)) return null;
|
||||
|
||||
const bsData = (bs || []).slice(0, 5).reverse().map((p: FinancialPeriod) => {
|
||||
const period = p as Record<string, unknown>;
|
||||
const ta = getValue(period, "TotalAssets|Total Assets");
|
||||
const tl = getValue(period, "TotalLiabilitiesNetMinorityInterest|Total Liabilities Net Minority Interest|TotalLiab|Total Liabilities");
|
||||
const te = getValue(period, "StockholdersEquity|Stockholders Equity|TotalStockholderEquity|Total Stockholder Equity");
|
||||
const cash = getValue(period, "CashAndCashEquivalents|Cash And Cash Equivalents|CashCashEquivalentsAndShortTermInvestments");
|
||||
const yr = (period.asOfDate || period.fiscalYear || "") as string | number;
|
||||
return {
|
||||
year: typeof yr === "string" ? yr.slice(0, 4) : String(yr),
|
||||
assets: ta ? ta / 1e9 : 0,
|
||||
liabilities: tl ? tl / 1e9 : 0,
|
||||
equity: te ? te / 1e9 : 0,
|
||||
cash: cash ? cash / 1e9 : 0,
|
||||
};
|
||||
});
|
||||
|
||||
const cfData = (cf || []).slice(0, 5).reverse().map((p: FinancialPeriod) => {
|
||||
const period = p as Record<string, unknown>;
|
||||
const ocf = getValue(period, "OperatingCashFlow|Operating Cash Flow|CashFlowFromContinuingOperatingActivities");
|
||||
const capex = getValue(period, "CapitalExpenditure|Capital Expenditure");
|
||||
const fcf = getValue(period, "FreeCashFlow|Free Cash Flow") ?? (ocf != null && capex != null ? ocf + capex : null);
|
||||
const yr = (period.asOfDate || period.fiscalYear || "") as string | number;
|
||||
return {
|
||||
year: typeof yr === "string" ? yr.slice(0, 4) : String(yr),
|
||||
ocf: ocf ? ocf / 1e9 : 0,
|
||||
capex: capex ? Math.abs(capex) / 1e9 : 0,
|
||||
fcf: fcf ? fcf / 1e9 : 0,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="report-section">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{bsData.length > 0 && (
|
||||
<div>
|
||||
<h3 className="chart-title">Balance Sheet ($B)</h3>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={bsData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" />
|
||||
<XAxis dataKey="year" tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="assets" fill={C.blue} name="Total Assets" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="equity" fill={C.green} name="Equity" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="cash" fill={C.gold} name="Cash" radius={[2, 2, 0, 0]} />
|
||||
<Legend wrapperStyle={{ fontSize: 10 }} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
{cfData.length > 0 && (
|
||||
<div>
|
||||
<h3 className="chart-title">Cash Flow ($B)</h3>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={cfData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" />
|
||||
<XAxis dataKey="year" tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="ocf" fill={C.blue} name="Operating CF" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="fcf" fill={C.green} name="Free CF" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="capex" fill={C.red} name="CapEx" radius={[2, 2, 0, 0]} />
|
||||
<Legend wrapperStyle={{ fontSize: 10 }} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QualityScores({ research, health }: { research: ResearchDash | null; health: HealthData | null }) {
|
||||
if (!research && !health) return null;
|
||||
const { fscore_total = 0, fscore_criteria = [], dupont_tree } = research || {};
|
||||
const altmanZ = health?.altman_z;
|
||||
const zLabel = altmanZ == null ? "N/A" : altmanZ > 2.99 ? "Safe" : altmanZ > 1.81 ? "Gray Zone" : "Distress";
|
||||
const zColor = altmanZ == null ? C.muted : altmanZ > 2.99 ? C.green : altmanZ > 1.81 ? C.gold : C.red;
|
||||
|
||||
return (
|
||||
<div className="report-section">
|
||||
<h2 className="section-title">Quality Assessment</h2>
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div>
|
||||
<h3 className="chart-title">Piotroski F-Score: {fscore_total}/9</h3>
|
||||
<div className="flex items-center gap-1 mb-3">
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<div key={i} className="h-6 flex-1 rounded-sm flex items-center justify-center text-[10px] font-bold"
|
||||
style={{ background: i < fscore_total ? C.green : "#E8ECF0", color: i < fscore_total ? "#fff" : C.muted }}>
|
||||
{i + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{fscore_criteria.map((c) => (
|
||||
<div key={c.key} className="flex items-center gap-2 text-[11px]">
|
||||
<span style={{ color: c.history[0]?.pass_flag ? C.green : C.red }}>{c.history[0]?.pass_flag ? "\u2713" : "\u2717"}</span>
|
||||
<span style={{ color: C.text }}>{c.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="chart-title">Financial Health</h3>
|
||||
<div className="p-4 rounded mb-3" style={{ background: "#F4F6F9" }}>
|
||||
<div className="text-center mb-2">
|
||||
<div className="text-[10px] uppercase" style={{ color: C.muted }}>Altman Z-Score</div>
|
||||
<div className="text-3xl font-mono font-bold" style={{ color: zColor }}>{altmanZ?.toFixed(2) ?? "N/A"}</div>
|
||||
<div className="text-[10px] font-bold" style={{ color: zColor }}>{zLabel}</div>
|
||||
</div>
|
||||
<div className="space-y-1 mt-3">
|
||||
{[
|
||||
{ l: "Current Ratio", v: health?.current_ratio?.toFixed(2) },
|
||||
{ l: "Interest Coverage", v: health?.interest_coverage?.toFixed(1) },
|
||||
{ l: "Debt/Equity", v: health?.debt_to_equity?.toFixed(2) },
|
||||
].map((r) => (
|
||||
<div key={r.l} className="flex justify-between text-[11px]">
|
||||
<span style={{ color: C.muted }}>{r.l}</span>
|
||||
<span className="font-mono" style={{ color: C.navy }}>{r.v ?? "N/A"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{health?.red_flags && health.red_flags.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[10px] uppercase font-bold mb-1" style={{ color: C.red }}>Red Flags</div>
|
||||
{health.red_flags.slice(0, 4).map((f, i) => (
|
||||
<div key={i} className="text-[10px] mb-0.5" style={{ color: C.red }}>• {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 (>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} — {info.exchange || ""}</p>
|
||||
<p className="text-base mb-6" style={{ color: C.muted }}>{info.sector || ""} • {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's AI analysis engine using Gemini AI and pre-computed
|
||||
quantitative data from public sources (SEC EDGAR, Yahoo Finance). This is NOT investment advice.
|
||||
All financial data is sourced from public filings and market data providers and may contain
|
||||
errors or be outdated. The AI-generated perspectives are simulated institutional viewpoints
|
||||
and do not represent the actual views of any named financial institution. Past performance
|
||||
does not guarantee future results. Always consult a qualified financial advisor before making
|
||||
investment decisions.
|
||||
</p>
|
||||
<div className="mt-8 text-center">
|
||||
<div className="text-xs tracking-[0.2em] uppercase" style={{ color: C.gold }}>ATLAS TERMINAL</div>
|
||||
<div className="text-[10px] mt-1" style={{ color: C.muted }}>Advanced Terminal for Liquid Asset Surveillance</div>
|
||||
<div className="text-[10px] mt-1" style={{ color: C.muted }}>Generated {new Date().toISOString().slice(0, 16).replace("T", " ")} UTC</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Morgan Stanley Blue design tokens used by the institutional report.
|
||||
*
|
||||
* NOTE: P1-8 will hoist these into `tailwind.config.ts` so the rest of the app
|
||||
* shares the same palette. Until then, both this constant and the global
|
||||
* tailwind tokens (Terminal Noir) coexist intentionally.
|
||||
*/
|
||||
export const C = {
|
||||
navy: "#1B2A4A",
|
||||
blue: "#2E5B9A",
|
||||
gold: "#C4A35A",
|
||||
green: "#2D8B5E",
|
||||
red: "#C0392B",
|
||||
gray: "#6B7B8D",
|
||||
lightGray: "#E8ECF0",
|
||||
bg: "#FFFFFF",
|
||||
text: "#1A1A2E",
|
||||
muted: "#6B7B8D",
|
||||
} as const;
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Formatting + lookup helpers shared across every report section.
|
||||
*
|
||||
* `getValue` implements the multi-key column lookup pattern documented in
|
||||
* `claude.md` §2.5 — yfinance and yahooquery use different field names for the
|
||||
* same metric, so callers pass a pipe-separated list of candidates.
|
||||
*/
|
||||
|
||||
/** Map ISO currency codes → display symbol and decimal places. */
|
||||
const CURRENCY_MAP: Record<string, { sym: string; decimals: number }> = {
|
||||
USD: { sym: "$", decimals: 2 },
|
||||
KRW: { sym: "₩", decimals: 0 },
|
||||
JPY: { sym: "¥", decimals: 0 },
|
||||
CNY: { sym: "¥", decimals: 2 },
|
||||
HKD: { sym: "HK$", decimals: 2 },
|
||||
EUR: { sym: "€", decimals: 2 },
|
||||
GBP: { sym: "£", decimals: 2 },
|
||||
INR: { sym: "₹", decimals: 0 },
|
||||
TWD: { sym: "NT$", decimals: 0 },
|
||||
SGD: { sym: "S$", decimals: 2 },
|
||||
CAD: { sym: "C$", decimals: 2 },
|
||||
AUD: { sym: "A$", decimals: 2 },
|
||||
};
|
||||
|
||||
export function currencySymbol(currency?: string | null): string {
|
||||
if (!currency) return "$";
|
||||
return CURRENCY_MAP[currency.toUpperCase()]?.sym ?? currency;
|
||||
}
|
||||
|
||||
export function fmtB(v: number | null | undefined, currency?: string | null): string {
|
||||
if (v == null || isNaN(v)) return "N/A";
|
||||
const s = currencySymbol(currency);
|
||||
if (Math.abs(v) >= 1e12) return `${s}${(v / 1e12).toFixed(1)}T`;
|
||||
if (Math.abs(v) >= 1e9) return `${s}${(v / 1e9).toFixed(1)}B`;
|
||||
if (Math.abs(v) >= 1e6) return `${s}${(v / 1e6).toFixed(0)}M`;
|
||||
return `${s}${v.toFixed(0)}`;
|
||||
}
|
||||
|
||||
export function fmtPct(v: number | null | undefined): string {
|
||||
if (v == null || isNaN(v)) return "N/A";
|
||||
return `${v >= 0 ? "+" : ""}${v.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function fmtPrice(v: number | null | undefined, currency?: string | null): string {
|
||||
if (v == null || isNaN(v)) return "N/A";
|
||||
const info = CURRENCY_MAP[(currency ?? "USD").toUpperCase()] ?? { sym: currencySymbol(currency), decimals: 2 };
|
||||
return `${info.sym}${v.toLocaleString("en-US", { minimumFractionDigits: info.decimals, maximumFractionDigits: info.decimals })}`;
|
||||
}
|
||||
|
||||
export function getValue(data: Record<string, unknown>, key: string): number | null {
|
||||
for (const k of key.split("|")) {
|
||||
const v = data[k.trim()];
|
||||
if (v != null && typeof v === "number" && !isNaN(v)) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function fetchJson<T = unknown>(url: string, opts?: RequestInit): Promise<T | null> {
|
||||
try {
|
||||
const r = await fetch(url, opts);
|
||||
return r.ok ? ((await r.json()) as T) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny markdown→HTML used by AI section bodies. Intentionally minimal — we
|
||||
* trust Gemini's structured output and avoid pulling in a full markdown lib.
|
||||
*/
|
||||
export function renderMarkdown(text: string): string {
|
||||
return text
|
||||
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/\n- /g, "<br/>• ")
|
||||
.replace(/\n\* /g, "<br/>• ")
|
||||
.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,
|
||||
};
|
||||
}
|
||||
@@ -1,20 +1,14 @@
|
||||
"use client";
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { BarChart3, Info } from "lucide-react";
|
||||
import {
|
||||
BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid,
|
||||
Tooltip, ResponsiveContainer, Cell, Legend, ReferenceLine,
|
||||
} from "recharts";
|
||||
import { useTicker } from "../lib/use-ticker";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
Design Tokens — "Morgan Stanley Blue"
|
||||
═══════════════════════════════════════════════════════════════════ */
|
||||
const C = {
|
||||
navy: "#1B2A4A", blue: "#2E5B9A", gold: "#C4A35A",
|
||||
green: "#2D8B5E", red: "#C0392B", gray: "#6B7B8D",
|
||||
lightGray: "#E8ECF0", bg: "#FFFFFF", text: "#1A1A2E", muted: "#6B7B8D",
|
||||
};
|
||||
import { C } from "./design-tokens";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
Types
|
||||
@@ -68,20 +62,31 @@ interface RelativeValData {
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
Utility
|
||||
═══════════════════════════════════════════════════════════════════ */
|
||||
function fmtB(v: number | null | undefined): string {
|
||||
const CURRENCY_META: Record<string, { sym: string; dec: number }> = {
|
||||
USD: { sym: "$", dec: 2 }, KRW: { sym: "₩", dec: 0 },
|
||||
JPY: { sym: "¥", dec: 0 }, CNY: { sym: "¥", dec: 2 },
|
||||
HKD: { sym: "HK$", dec: 2 }, EUR: { sym: "€", dec: 2 },
|
||||
GBP: { sym: "£", dec: 2 }, INR: { sym: "₹", dec: 0 },
|
||||
TWD: { sym: "NT$", dec: 0 }, SGD: { sym: "S$", dec: 2 },
|
||||
CAD: { sym: "C$", dec: 2 }, AUD: { sym: "A$", dec: 2 },
|
||||
};
|
||||
function curSym(c?: string | null) { return CURRENCY_META[(c ?? "USD").toUpperCase()]?.sym ?? "$"; }
|
||||
function fmtB(v: number | null | undefined, c?: string | null): string {
|
||||
if (v == null || isNaN(v)) return "N/A";
|
||||
if (Math.abs(v) >= 1e12) return `$${(v / 1e12).toFixed(1)}T`;
|
||||
if (Math.abs(v) >= 1e9) return `$${(v / 1e9).toFixed(1)}B`;
|
||||
if (Math.abs(v) >= 1e6) return `$${(v / 1e6).toFixed(0)}M`;
|
||||
return `$${v.toFixed(0)}`;
|
||||
const s = curSym(c);
|
||||
if (Math.abs(v) >= 1e12) return `${s}${(v / 1e12).toFixed(1)}T`;
|
||||
if (Math.abs(v) >= 1e9) return `${s}${(v / 1e9).toFixed(1)}B`;
|
||||
if (Math.abs(v) >= 1e6) return `${s}${(v / 1e6).toFixed(0)}M`;
|
||||
return `${s}${v.toFixed(0)}`;
|
||||
}
|
||||
function fmtPct(v: number | null | undefined): string {
|
||||
if (v == null || isNaN(v)) return "N/A";
|
||||
return `${v >= 0 ? "+" : ""}${v.toFixed(1)}%`;
|
||||
}
|
||||
function fmtPrice(v: number | null | undefined): string {
|
||||
function fmtPrice(v: number | null | undefined, c?: string | null): string {
|
||||
if (v == null || isNaN(v)) return "N/A";
|
||||
return `$${v.toFixed(2)}`;
|
||||
const meta = CURRENCY_META[(c ?? "USD").toUpperCase()] ?? { sym: curSym(c), dec: 2 };
|
||||
return `${meta.sym}${v.toLocaleString("en-US", { minimumFractionDigits: meta.dec, maximumFractionDigits: meta.dec })}`;
|
||||
}
|
||||
function getValue(data: Record<string, any>, key: string): number | null {
|
||||
for (const k of key.split("|")) { const v = data[k.trim()]; if (v != null && typeof v === "number" && !isNaN(v)) return v; }
|
||||
@@ -93,6 +98,28 @@ async function fetchJson(url: string, opts?: RequestInit) {
|
||||
function renderMarkdown(text: string) {
|
||||
return text.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>").replace(/\n- /g, "<br/>• ").replace(/\n\* /g, "<br/>• ").replace(/\n/g, "<br/>");
|
||||
}
|
||||
function normalizeAiNotice(message?: string | null): string {
|
||||
const text = (message || "").trim();
|
||||
if (!text) {
|
||||
return "AI analysis could not be generated. The quantitative report was generated successfully.";
|
||||
}
|
||||
if (text.includes("Gemini API 키가 없어")) {
|
||||
return "Gemini API key is missing. Add your key in Settings to enable the Wall Street 10 analysis.";
|
||||
}
|
||||
if (text.includes("AI 분석 호출에 실패했습니다")) {
|
||||
return "AI analysis could not be generated. The quantitative report was generated successfully.";
|
||||
}
|
||||
if (text.includes("Gemini API timeout")) {
|
||||
return "Gemini timed out while generating the AI section. The quantitative report was generated successfully.";
|
||||
}
|
||||
if (text.includes("Gemini API error")) {
|
||||
return "Gemini returned an API error. The quantitative report was generated successfully.";
|
||||
}
|
||||
if (text.includes("Gemini request failed")) {
|
||||
return "Gemini request failed. The quantitative report was generated successfully.";
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
SECTION COMPONENTS
|
||||
@@ -101,11 +128,14 @@ function renderMarkdown(text: string) {
|
||||
function CoverPage({ ticker, info, consensus, dcf, relativeVal }: { ticker: string; info: Record<string, any>; consensus: ConsensusData | null; dcf: DCFResult | null; relativeVal: RelativeValData | null }) {
|
||||
const now = new Date();
|
||||
const dateStr = now.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
|
||||
const cur: string = info.currency || "USD";
|
||||
const price = info.currentPrice || info.regularMarketPrice || 0;
|
||||
const target = consensus?.target_mean ?? info.targetMeanPrice ?? 0;
|
||||
const upside = price > 0 && target > 0 ? ((target - price) / price) * 100 : 0;
|
||||
const rec = consensus?.recommendation?.toUpperCase() || "N/A";
|
||||
const recColor = rec.includes("BUY") || rec.includes("STRONG") ? C.green : rec.includes("SELL") ? C.red : C.gold;
|
||||
// NONE / null / missing consensus → show "N/A" rather than "NONE"
|
||||
const recDisplay = !rec || rec === "NONE" || rec === "N/A" ? "N/A" : rec;
|
||||
const recColor = recDisplay.includes("BUY") || recDisplay.includes("STRONG") ? C.green : recDisplay.includes("SELL") ? C.red : C.gold;
|
||||
|
||||
return (
|
||||
<div className="report-page cover-page flex flex-col items-center justify-center text-center">
|
||||
@@ -121,11 +151,11 @@ function CoverPage({ ticker, info, consensus, dcf, relativeVal }: { ticker: stri
|
||||
{/* Rating Badge */}
|
||||
<div className="inline-flex items-center gap-3 px-6 py-3 rounded-lg mb-8" style={{ background: "#F4F6F9", border: `2px solid ${recColor}` }}>
|
||||
<div className="text-xs uppercase tracking-wider" style={{ color: C.muted }}>Consensus</div>
|
||||
<div className="text-2xl font-bold font-mono" style={{ color: recColor }}>{rec}</div>
|
||||
<div className="text-2xl font-bold font-mono" style={{ color: recColor }}>{recDisplay}</div>
|
||||
<div className="w-px h-8" style={{ background: C.lightGray }} />
|
||||
<div className="text-right">
|
||||
<div className="text-xs" style={{ color: C.muted }}>Target</div>
|
||||
<div className="font-mono font-bold" style={{ color: C.navy }}>{fmtPrice(target)}</div>
|
||||
<div className="font-mono font-bold" style={{ color: C.navy }}>{fmtPrice(target, cur)}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs" style={{ color: C.muted }}>Upside</div>
|
||||
@@ -135,9 +165,9 @@ function CoverPage({ ticker, info, consensus, dcf, relativeVal }: { ticker: stri
|
||||
|
||||
<div className="grid grid-cols-4 gap-6 mb-8" style={{ maxWidth: 640 }}>
|
||||
{[
|
||||
{ label: "Price", value: fmtPrice(price) },
|
||||
{ label: "Market Cap", value: fmtB(info.marketCap) },
|
||||
{ label: relativeVal ? `${relativeVal.method} Fair Value` : "DCF Fair Value", value: relativeVal ? fmtPrice(relativeVal.base.value) : dcf?.base != null ? fmtPrice(dcf.base) : "N/A" },
|
||||
{ label: "Price", value: fmtPrice(price, cur) },
|
||||
{ label: "Market Cap", value: fmtB(info.marketCap, cur) },
|
||||
{ label: relativeVal ? `${relativeVal.method} Fair Value` : "DCF Fair Value", value: relativeVal ? fmtPrice(relativeVal.base.value, cur) : dcf?.base != null ? fmtPrice(dcf.base, cur) : "N/A" },
|
||||
{ label: "Analysts", value: consensus ? `${consensus.num_analysts}` : "N/A" },
|
||||
].map((k) => (
|
||||
<div key={k.label}>
|
||||
@@ -191,10 +221,11 @@ function TableOfContents({ hasInstitutional }: { hasInstitutional: boolean }) {
|
||||
}
|
||||
|
||||
function KpiRow({ info }: { info: Record<string, any> }) {
|
||||
const cur: string = info.currency || "USD";
|
||||
const kpis = [
|
||||
{ label: "Revenue", value: fmtB(info.totalRevenue), sub: fmtPct((info.revenueGrowth || 0) * 100) },
|
||||
{ label: "Net Income", value: fmtB(info.netIncomeToCommon), sub: `Margin ${((info.profitMargins || 0) * 100).toFixed(1)}%` },
|
||||
{ label: "Free Cash Flow", value: fmtB(info.freeCashflow), sub: `Yield ${info.freeCashflow && info.marketCap ? ((info.freeCashflow / info.marketCap) * 100).toFixed(1) : "N/A"}%` },
|
||||
{ label: "Revenue", value: fmtB(info.totalRevenue, cur), sub: fmtPct((info.revenueGrowth || 0) * 100) },
|
||||
{ label: "Net Income", value: fmtB(info.netIncomeToCommon, cur), sub: `Margin ${((info.profitMargins || 0) * 100).toFixed(1)}%` },
|
||||
{ label: "Free Cash Flow", value: fmtB(info.freeCashflow, cur), sub: `Yield ${info.freeCashflow && info.marketCap ? ((info.freeCashflow / info.marketCap) * 100).toFixed(1) : "N/A"}%` },
|
||||
{ label: "ROE", value: `${((info.returnOnEquity || 0) * 100).toFixed(1)}%`, sub: `ROA ${((info.returnOnAssets || 0) * 100).toFixed(1)}%` },
|
||||
{ label: "P/E (TTM)", value: info.trailingPE ? `${info.trailingPE.toFixed(1)}x` : "N/A", sub: `Fwd ${info.forwardPE ? info.forwardPE.toFixed(1) + "x" : "N/A"}` },
|
||||
{ label: "EV/EBITDA", value: info.enterpriseToEbitda ? `${info.enterpriseToEbitda.toFixed(1)}x` : "N/A", sub: `D/E ${info.debtToEquity ?? "N/A"}` },
|
||||
@@ -215,12 +246,13 @@ function KpiRow({ info }: { info: Record<string, any> }) {
|
||||
function CompanyProfile({ info }: { info: Record<string, any> }) {
|
||||
const desc = info.longBusinessSummary || info.description || "";
|
||||
if (!desc) return null;
|
||||
const cur: string = info.currency || "USD";
|
||||
const stats = [
|
||||
{ l: "Employees", v: info.fullTimeEmployees?.toLocaleString() || "N/A" },
|
||||
{ l: "Country", v: info.country || "N/A" },
|
||||
{ l: "Founded", v: info.companyOfficers?.[0]?.fiscalYear || "N/A" },
|
||||
{ l: "52W High", v: fmtPrice(info.fiftyTwoWeekHigh) },
|
||||
{ l: "52W Low", v: fmtPrice(info.fiftyTwoWeekLow) },
|
||||
{ l: "Employees", v: info.fullTimeEmployees ? Number(info.fullTimeEmployees).toLocaleString() : "—" },
|
||||
{ l: "Country", v: info.country || "—" },
|
||||
{ l: "Exchange", v: info.exchange || "—" },
|
||||
{ l: "52W High", v: fmtPrice(info.fiftyTwoWeekHigh, cur) },
|
||||
{ l: "52W Low", v: fmtPrice(info.fiftyTwoWeekLow, cur) },
|
||||
{ l: "Beta", v: info.beta ? info.beta.toFixed(2) : "N/A" },
|
||||
{ l: "Avg Volume", v: info.averageVolume ? `${(info.averageVolume / 1e6).toFixed(1)}M` : "N/A" },
|
||||
{ l: "Dividend Yield", v: info.dividendYield ? `${(info.dividendYield * 100).toFixed(2)}%` : "N/A" },
|
||||
@@ -285,7 +317,7 @@ function FinancialCharts({ statements }: { statements: { income_statement?: Fina
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" />
|
||||
<XAxis dataKey="year" tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} />
|
||||
<Tooltip />
|
||||
<Tooltip formatter={(value: number | string) => `${Number(value).toFixed(1)}%`} />
|
||||
<Line type="monotone" dataKey="grossMargin" stroke={C.green} name="Gross" strokeWidth={2} dot={{ r: 3 }} />
|
||||
<Line type="monotone" dataKey="opMargin" stroke={C.blue} name="Operating" strokeWidth={2} dot={{ r: 3 }} />
|
||||
<Line type="monotone" dataKey="netMargin" stroke={C.gold} name="Net" strokeWidth={2} dot={{ r: 3 }} />
|
||||
@@ -1102,9 +1134,10 @@ export default function ReportPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [progress, setProgress] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [aiNotice, setAiNotice] = useState("");
|
||||
|
||||
const generateReport = useCallback(async () => {
|
||||
setLoading(true); setError(""); setProgress("Phase 1/4 — Gathering market data (9 parallel requests)...");
|
||||
setLoading(true); setError(""); setAiNotice(""); setProgress("Phase 1/4 — Gathering market data (9 parallel requests)...");
|
||||
const apiKey = localStorage.getItem("atlas_gemini_key") || "";
|
||||
|
||||
try {
|
||||
@@ -1130,27 +1163,48 @@ export default function ReportPage() {
|
||||
const mergedInfo: Record<string, any> = {};
|
||||
if (ov?.data) Object.assign(mergedInfo, {
|
||||
longName: ov.data.name, sector: ov.data.sector, industry: ov.data.industry,
|
||||
marketCap: ov.data.market_cap, trailingPE: ov.data.pe_ratio,
|
||||
marketCap: ov.data.market_cap,
|
||||
trailingPE: ov.data.trailing_pe ?? ov.data.pe_ratio,
|
||||
dividendYield: ov.data.dividend_yield ? ov.data.dividend_yield / 100 : 0,
|
||||
beta: ov.data.beta, fiftyTwoWeekHigh: ov.data.high_52w, fiftyTwoWeekLow: ov.data.low_52w,
|
||||
currentPrice: ov.data.price, longBusinessSummary: ov.data.description,
|
||||
currency: ov.data.currency,
|
||||
exchange: ov.data.exchange, country: ov.data.country,
|
||||
fullTimeEmployees: ov.data.full_time_employees,
|
||||
averageVolume: ov.data.average_volume,
|
||||
forwardPE: ov.data.forward_pe,
|
||||
enterpriseToEbitda: ov.data.enterprise_to_ebitda,
|
||||
debtToEquity: ov.data.debt_to_equity,
|
||||
returnOnEquity: ov.data.return_on_equity,
|
||||
returnOnAssets: ov.data.return_on_assets,
|
||||
freeCashflow: ov.data.free_cashflow,
|
||||
revenueGrowth: ov.data.revenue_growth,
|
||||
profitMargins: ov.data.profit_margins,
|
||||
targetMeanPrice: ov.data.target_mean_price,
|
||||
});
|
||||
if (sec) Object.assign(mergedInfo, {
|
||||
sector: sec.sector || mergedInfo.sector, industry: sec.industry || mergedInfo.industry,
|
||||
marketCap: sec.market_cap || mergedInfo.marketCap, trailingPE: sec.pe_ratio || mergedInfo.trailingPE,
|
||||
forwardPE: sec.forward_pe, currentPrice: sec.current_price || mergedInfo.currentPrice,
|
||||
targetMeanPrice: sec.target_mean_price, fiftyTwoWeekHigh: sec.fifty_two_week_high || mergedInfo.fiftyTwoWeekHigh,
|
||||
forwardPE: sec.forward_pe || mergedInfo.forwardPE,
|
||||
currentPrice: sec.current_price || mergedInfo.currentPrice,
|
||||
targetMeanPrice: sec.target_mean_price || mergedInfo.targetMeanPrice,
|
||||
fiftyTwoWeekHigh: sec.fifty_two_week_high || mergedInfo.fiftyTwoWeekHigh,
|
||||
fiftyTwoWeekLow: sec.fifty_two_week_low || mergedInfo.fiftyTwoWeekLow,
|
||||
fullTimeEmployees: sec.employees, exchange: sec.exchange,
|
||||
debtToEquity: sec.debt_to_equity,
|
||||
fullTimeEmployees: sec.employees || mergedInfo.fullTimeEmployees,
|
||||
exchange: sec.exchange || mergedInfo.exchange,
|
||||
debtToEquity: sec.debt_to_equity || mergedInfo.debtToEquity,
|
||||
currency: sec.currency || mergedInfo.currency || "USD",
|
||||
});
|
||||
if (hi) Object.assign(mergedInfo, {
|
||||
totalRevenue: hi.revenue, profitMargins: hi.profit_margin,
|
||||
returnOnEquity: hi.roe, returnOnAssets: hi.roa, freeCashflow: hi.free_cash_flow,
|
||||
totalRevenue: hi.revenue, profitMargins: hi.profit_margin ?? mergedInfo.profitMargins,
|
||||
returnOnEquity: hi.roe ?? mergedInfo.returnOnEquity,
|
||||
returnOnAssets: hi.roa ?? mergedInfo.returnOnAssets,
|
||||
freeCashflow: hi.free_cash_flow ?? mergedInfo.freeCashflow,
|
||||
netIncomeToCommon: hi.revenue && hi.profit_margin ? hi.revenue * hi.profit_margin : undefined,
|
||||
enterpriseToEbitda: hi.ebitda && hi.revenue ? undefined : undefined,
|
||||
revenueGrowth: hi.revenue_growth, debtToEquity: hi.debt_to_equity ?? mergedInfo.debtToEquity,
|
||||
revenueGrowth: hi.revenue_growth ?? mergedInfo.revenueGrowth,
|
||||
debtToEquity: hi.debt_to_equity ?? mergedInfo.debtToEquity,
|
||||
operatingMargins: hi.operating_margin, grossMargins: hi.gross_margin,
|
||||
currency: hi.currency || mergedInfo.currency || "USD",
|
||||
});
|
||||
if (Object.keys(mergedInfo).length > 0) setInfo(mergedInfo);
|
||||
|
||||
@@ -1226,7 +1280,7 @@ export default function ReportPage() {
|
||||
|
||||
/* ── Phase 4: Gemini AI ── */
|
||||
if (!apiKey) {
|
||||
setError("Quantitative report generated (20+ sections). Add Gemini API key in Settings for Wall Street 10 AI analysis.");
|
||||
setAiNotice(normalizeAiNotice("Gemini API key is missing. Add your key in Settings to enable the Wall Street 10 analysis."));
|
||||
} else {
|
||||
setProgress("Phase 4/4 — Generating Wall Street 10 analysis (Gemini AI, ~15s)...");
|
||||
const instRes = await fetch("/api/analysis/institutional", {
|
||||
@@ -1234,7 +1288,10 @@ export default function ReportPage() {
|
||||
body: JSON.stringify({ ticker, api_key: apiKey }),
|
||||
});
|
||||
if (instRes.ok) { setInstitutional(await instRes.json()); }
|
||||
else { const err = await instRes.json().catch(() => ({})); setError(err.detail || "AI analysis failed. Quantitative data available below."); }
|
||||
else {
|
||||
const err = await instRes.json().catch(() => ({}));
|
||||
setAiNotice(normalizeAiNotice(err.detail || "AI analysis could not be generated. The quantitative report was generated successfully."));
|
||||
}
|
||||
}
|
||||
|
||||
setProgress("");
|
||||
@@ -1274,6 +1331,12 @@ export default function ReportPage() {
|
||||
</div>
|
||||
|
||||
{error && <div className="bg-accent-red/10 border border-accent-red/30 rounded-md px-4 py-3 text-accent-red text-sm mb-4">{error}</div>}
|
||||
{!error && aiNotice && (
|
||||
<div className="mb-4 flex items-start gap-2 rounded-md border border-accent-yellow/30 bg-accent-yellow/10 px-4 py-3 text-sm text-accent-yellow">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{aiNotice}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-8 text-center">
|
||||
@@ -1286,7 +1349,9 @@ export default function ReportPage() {
|
||||
|
||||
{!hasData && !loading && (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-8 text-center">
|
||||
<div className="text-5xl mb-4">📊</div>
|
||||
<div className="mb-4 flex justify-center text-brand-navy">
|
||||
<BarChart3 className="h-12 w-12" />
|
||||
</div>
|
||||
<h2 className="text-text-primary text-lg font-semibold mb-2">Wall Street 10 Institutional Report</h2>
|
||||
<p className="text-text-muted text-sm max-w-lg mx-auto mb-4">
|
||||
Generate a comprehensive 20+ page equity research report featuring DCF valuation (3 scenarios),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Type definitions for the institutional report data flow.
|
||||
*
|
||||
* Many of the upstream API responses are still untyped on the backend
|
||||
* (`Dict[str, Any]`), so a number of fields keep loose `Record<string, unknown>`
|
||||
* shapes. P1-4 / P2-1 will tighten these as Pydantic response models land.
|
||||
*/
|
||||
|
||||
export type FinancialPeriod = Record<string, unknown>;
|
||||
|
||||
export interface ResearchDash {
|
||||
ticker: string;
|
||||
fscore_total: number;
|
||||
fscore_criteria: { key: string; label: string; history: { year: number; pass_flag: boolean }[] }[];
|
||||
dupont_tree?: {
|
||||
root: { value: number };
|
||||
npm: { value: number; trend: string };
|
||||
asset_turnover: { value: number; trend: string };
|
||||
equity_mult: { value: number; trend: string };
|
||||
};
|
||||
sankey: unknown;
|
||||
waterfall: { id: string; label: string; value: number; cumulative: number; step_type: string }[];
|
||||
anomalies: { account_key: string; display_name: string; change_pct: number; direction: string }[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface InstitutionalData {
|
||||
ticker: string;
|
||||
sections: Record<string, string>;
|
||||
quant_context: string;
|
||||
}
|
||||
|
||||
export interface DCFResult {
|
||||
base: number | null;
|
||||
bull: number | null;
|
||||
bear: number | null;
|
||||
current_price: number | null;
|
||||
scenarios: Record<string, { intrinsic_value: number | null; upside: number }>;
|
||||
}
|
||||
|
||||
export interface SensitivityResult {
|
||||
wacc_values: number[];
|
||||
tg_values: number[];
|
||||
matrix: (number | null)[][];
|
||||
}
|
||||
|
||||
export interface MonteCarloResult {
|
||||
histogram: { counts: number[]; bin_edges: number[] };
|
||||
mean: number | null;
|
||||
median: number | null;
|
||||
percentile_5: number | null;
|
||||
percentile_95: number | null;
|
||||
upside_pct: number | null;
|
||||
current_price: number | null;
|
||||
}
|
||||
|
||||
export interface TornadoItem {
|
||||
variable: string;
|
||||
low: number;
|
||||
high: number;
|
||||
base: number;
|
||||
}
|
||||
|
||||
export interface ReverseDCFResult {
|
||||
implied_growth: number | null;
|
||||
current_price: number | null;
|
||||
}
|
||||
|
||||
export interface ConsensusData {
|
||||
target_mean: number | null;
|
||||
target_high: number | null;
|
||||
target_low: number | null;
|
||||
target_median: number | null;
|
||||
recommendation: string;
|
||||
num_analysts: number;
|
||||
}
|
||||
|
||||
export interface PeerData {
|
||||
ticker: string;
|
||||
sector: string;
|
||||
industry: string;
|
||||
averages: Record<string, number | null>;
|
||||
peers: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export interface EarningsHistory {
|
||||
date: string;
|
||||
eps_actual: number | null;
|
||||
eps_estimate: number | null;
|
||||
surprise: number;
|
||||
}
|
||||
|
||||
export interface QuarterlyEarnings {
|
||||
period: string;
|
||||
revenue: number | null;
|
||||
earnings: number | null;
|
||||
}
|
||||
|
||||
export interface HealthData {
|
||||
dupont: Record<string, number>;
|
||||
altman_z: number | null;
|
||||
current_ratio: number | null;
|
||||
interest_coverage: number | null;
|
||||
debt_to_equity: number | null;
|
||||
red_flags: string[];
|
||||
}
|
||||
|
||||
export type TechnicalData = Record<string, unknown>;
|
||||
|
||||
export type ValuationTier = "dcf" | "ev_ebitda" | "ps_revenue" | "pb_nav";
|
||||
|
||||
export interface RelativeValData {
|
||||
tier: ValuationTier;
|
||||
tierLabel: string;
|
||||
tierReason: string;
|
||||
method: string;
|
||||
multipleName: string;
|
||||
peerAvgMultiple: number;
|
||||
companyMetric: number;
|
||||
metricLabel: string;
|
||||
bear: { multiple: number; value: number };
|
||||
base: { multiple: number; value: number };
|
||||
bull: { multiple: number; value: number };
|
||||
netDebt: number;
|
||||
shares: number;
|
||||
cashRunwayQuarters: number | null;
|
||||
revenueGrowth: number | null;
|
||||
rule40: number | null;
|
||||
ebitda: number | null;
|
||||
fcf: number | null;
|
||||
}
|
||||
|
||||
export type InfoMap = Record<string, unknown>;
|
||||
|
||||
export interface StatementsBundle {
|
||||
income_statement?: FinancialPeriod[];
|
||||
balance_sheet?: FinancialPeriod[];
|
||||
cash_flow?: FinancialPeriod[];
|
||||
}
|
||||
@@ -1,83 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ResearchGridLayout } from "../components/research/ResearchGridLayout";
|
||||
import type { ResearchDashboardPayload } from "../components/research/types";
|
||||
import { ErrorBanner } from "../components/ui/ErrorBanner";
|
||||
import { LoadingPulse } from "../components/ui/LoadingPulse";
|
||||
import { SectionHeading } from "../components/ui/SectionHeading";
|
||||
import { useApi } from "../lib/use-api";
|
||||
import { useTicker } from "../lib/use-ticker";
|
||||
|
||||
interface OverviewResp {
|
||||
asset_type?: string;
|
||||
}
|
||||
|
||||
export default function ResearchPage() {
|
||||
const { ticker, initialized } = useTicker();
|
||||
const [assetType, setAssetType] = useState<string>("equity");
|
||||
const [dashboard, setDashboard] = useState<ResearchDashboardPayload | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized) return;
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
Promise.all([
|
||||
fetch(`/api/market/overview/${ticker}`).then((r) => (r.ok ? r.json() : { asset_type: "equity" })),
|
||||
fetch(`/api/research/dashboard/${encodeURIComponent(ticker)}`).then(async (r) => {
|
||||
if (!r.ok) {
|
||||
const errText = await r.text();
|
||||
throw new Error(errText || `HTTP ${r.status}`);
|
||||
}
|
||||
return r.json();
|
||||
}),
|
||||
])
|
||||
.then(([overview, dash]) => {
|
||||
setAssetType(overview?.asset_type || "equity");
|
||||
setDashboard(dash as ResearchDashboardPayload);
|
||||
})
|
||||
.catch(() => {
|
||||
setLoadError("Failed to load dashboard data.");
|
||||
setDashboard(null);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [ticker, initialized]);
|
||||
const overviewUrl = initialized ? `/api/market/overview/${ticker}` : null;
|
||||
const dashUrl = initialized ? `/api/research/dashboard/${encodeURIComponent(ticker)}` : null;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-accent-green animate-pulse font-mono">Loading research…</div>
|
||||
</div>
|
||||
);
|
||||
const overview = useApi<OverviewResp>(overviewUrl);
|
||||
const dashboard = useApi<ResearchDashboardPayload>(dashUrl);
|
||||
|
||||
const assetType = overview.data?.asset_type || "equity";
|
||||
const loading = overview.loading || dashboard.loading;
|
||||
|
||||
if (!initialized || loading) {
|
||||
return <LoadingPulse label="Loading research…" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">
|
||||
<span className="text-accent-green">{ticker}</span> Research
|
||||
</h1>
|
||||
<div className="atlas-page">
|
||||
<SectionHeading level={1}>{ticker} Research</SectionHeading>
|
||||
|
||||
{assetType === "equity" ? (
|
||||
loadError ? (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5 text-accent-red text-sm">{loadError}</div>
|
||||
) : dashboard ? (
|
||||
dashboard.error ? (
|
||||
<ErrorBanner
|
||||
variant="info"
|
||||
message={`${dashboard.error} Some quant widgets may be unavailable — try refreshing or checking the backend.`}
|
||||
/>
|
||||
) : dashboard.data ? (
|
||||
<>
|
||||
{dashboard.error && (
|
||||
<div className="mb-4 rounded-lg border border-accent-yellow/40 bg-bg-card px-4 py-3 text-sm text-accent-yellow">
|
||||
{dashboard.error} — Some widgets may be empty.
|
||||
</div>
|
||||
{dashboard.data.error && (
|
||||
<ErrorBanner
|
||||
className="mb-4"
|
||||
variant="info"
|
||||
message={`${dashboard.data.error} — Some widgets may be empty.`}
|
||||
/>
|
||||
)}
|
||||
<ResearchGridLayout dashboard={dashboard} />
|
||||
<ResearchGridLayout dashboard={dashboard.data} />
|
||||
</>
|
||||
) : (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5 text-text-muted text-sm">
|
||||
<div className="atlas-card p-5 text-sm text-text-muted">
|
||||
No dashboard data available. Check the API response or try a different ticker.
|
||||
</div>
|
||||
)
|
||||
) : assetType === "etf" ? (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5 mb-6">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">ETF Research</h3>
|
||||
<div className="atlas-card p-5 mb-6">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-[0.12em] text-text-secondary">ETF Research</h3>
|
||||
<div className="text-text-secondary text-sm">
|
||||
Provides Holdings Analysis, Sector Breakdown, and Overlap Analysis. Piotroski F-Score and corporate financial dashboards are not applicable to ETFs.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5 mb-6">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">Commodity Research</h3>
|
||||
<div className="atlas-card p-5 mb-6">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-[0.12em] text-text-secondary">Commodity Research</h3>
|
||||
<div className="text-text-secondary text-sm">
|
||||
Focuses on Seasonal Analysis and Supply/Demand factors. Equity-specific indicators are not displayed.
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { chartPalette, lightweightTheme } from "../lib/chart-theme";
|
||||
|
||||
interface ScreenerRow {
|
||||
ticker: string;
|
||||
@@ -156,19 +157,17 @@ export default function ScreenerPage() {
|
||||
chart = createChart(el, {
|
||||
width: el.clientWidth,
|
||||
height: 320,
|
||||
layout: { background: { color: "#1A1A26" }, textColor: "#9CA3AF" },
|
||||
grid: { vertLines: { color: "#2A2A3A" }, horzLines: { color: "#2A2A3A" } },
|
||||
...lightweightTheme,
|
||||
crosshair: { mode: 0 },
|
||||
timeScale: { borderColor: "#2A2A3A" },
|
||||
});
|
||||
const strat = chart.addLineSeries({ color: "#00D4AA", lineWidth: 2 });
|
||||
const strat = chart.addLineSeries({ color: chartPalette.navy, lineWidth: 2 });
|
||||
strat.setData(
|
||||
btResult.dates!.map((d, i) => ({
|
||||
time: d as string & { __brand?: "Time" },
|
||||
value: btResult.equity_curve![i],
|
||||
}))
|
||||
);
|
||||
const bench = chart.addLineSeries({ color: "#4DA6FF", lineWidth: 2 });
|
||||
const bench = chart.addLineSeries({ color: chartPalette.blue, lineWidth: 2 });
|
||||
bench.setData(
|
||||
btResult.dates!.map((d, i) => ({
|
||||
time: d as string & { __brand?: "Time" },
|
||||
@@ -203,19 +202,17 @@ export default function ScreenerPage() {
|
||||
chart = createChart(el, {
|
||||
width: el.clientWidth,
|
||||
height: 320,
|
||||
layout: { background: { color: "#1A1A26" }, textColor: "#9CA3AF" },
|
||||
grid: { vertLines: { color: "#2A2A3A" }, horzLines: { color: "#2A2A3A" } },
|
||||
...lightweightTheme,
|
||||
crosshair: { mode: 0 },
|
||||
timeScale: { borderColor: "#2A2A3A" },
|
||||
});
|
||||
const port = chart.addLineSeries({ color: "#00D4AA", lineWidth: 2 });
|
||||
const port = chart.addLineSeries({ color: chartPalette.navy, lineWidth: 2 });
|
||||
port.setData(
|
||||
ptResult.dates!.map((d, i) => ({
|
||||
time: d as string & { __brand?: "Time" },
|
||||
value: ptResult.equity_curve![i],
|
||||
}))
|
||||
);
|
||||
const bench = chart.addLineSeries({ color: "#4DA6FF", lineWidth: 2 });
|
||||
const bench = chart.addLineSeries({ color: chartPalette.blue, lineWidth: 2 });
|
||||
bench.setData(
|
||||
ptResult.dates!.map((d, i) => ({
|
||||
time: d as string & { __brand?: "Time" },
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import { CheckCircle2, CircleOff, LoaderCircle } from "lucide-react";
|
||||
import { Card } from "../components/ui/Card";
|
||||
import { SectionHeading } from "../components/ui/SectionHeading";
|
||||
|
||||
const KEYS = [
|
||||
{ id: "atlas_gemini_key", label: "Gemini API Key", placeholder: "AIza..." },
|
||||
@@ -36,36 +39,38 @@ export default function SettingsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<h1 className="text-2xl font-bold mb-6">Settings</h1>
|
||||
<div className="atlas-page max-w-3xl">
|
||||
<SectionHeading level={1}>Settings</SectionHeading>
|
||||
|
||||
{/* Backend Status */}
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4 mb-6">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">System Status</h3>
|
||||
<Card title="System Status" subtitle="Local keys and backend health for your desk.">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-3 h-3 rounded-full ${backendStatus === "ok" ? "bg-accent-green" : backendStatus === "error" ? "bg-accent-red" : "bg-accent-yellow animate-pulse"}`} />
|
||||
{backendStatus === "ok" ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-fin-positive" />
|
||||
) : backendStatus === "error" ? (
|
||||
<CircleOff className="h-5 w-5 text-fin-negative" />
|
||||
) : (
|
||||
<LoaderCircle className="h-5 w-5 animate-spin text-brand-blue" />
|
||||
)}
|
||||
<span className="text-text-primary text-sm">
|
||||
Backend API: {backendStatus === "ok" ? "Connected" : backendStatus === "error" ? "Disconnected" : "Checking..."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* API Keys */}
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5 mb-6">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-4">API Keys</h3>
|
||||
<Card title="API Keys" subtitle="Stored locally in this browser session.">
|
||||
<div className="space-y-4">
|
||||
{KEYS.map((k) => (
|
||||
<div key={k.id}>
|
||||
<label className="text-text-muted text-sm mb-1.5 block">{k.label}</label>
|
||||
<label className="mb-1.5 block text-sm text-text-muted">{k.label}</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="password"
|
||||
value={values[k.id] || ""}
|
||||
onChange={(e) => setValues({ ...values, [k.id]: e.target.value })}
|
||||
placeholder={k.placeholder}
|
||||
className="flex-1 bg-bg-primary border border-border rounded-md px-3 py-2 text-text-primary outline-none focus:border-accent-green transition-colors"
|
||||
className="flex-1 rounded-md border border-border bg-surface-raised px-3 py-2 text-text-primary outline-none transition-colors focus:border-brand-blue"
|
||||
/>
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${values[k.id] ? "bg-accent-green" : "bg-text-muted"}`} />
|
||||
<div className={`h-2.5 w-2.5 rounded-full ${values[k.id] ? "bg-fin-positive" : "bg-text-muted"}`} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -73,20 +78,18 @@ export default function SettingsPage() {
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="mt-5 bg-accent-green text-bg-primary px-6 py-2 rounded-md font-semibold hover:opacity-90 transition-opacity"
|
||||
className="mt-5 rounded-md bg-brand-navy px-6 py-2 font-semibold text-white transition-colors hover:bg-brand-blue"
|
||||
>
|
||||
{saved ? "Saved!" : "Save Keys"}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Info */}
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-2">About</h3>
|
||||
<Card title="About">
|
||||
<div className="text-text-muted text-sm space-y-1">
|
||||
<p>ATLAS Terminal v2.0 — Advanced Trading & Liquidity Analysis System</p>
|
||||
<p>API keys are stored locally in your browser. They are never sent to our servers.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"use client";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { ChartContainer } from "../components/ui/ChartContainer";
|
||||
import { LoadingPulse } from "../components/ui/LoadingPulse";
|
||||
import { SectionHeading } from "../components/ui/SectionHeading";
|
||||
import { StatCard } from "../components/ui/StatCard";
|
||||
import { chartPalette, lightweightTheme } from "../lib/chart-theme";
|
||||
import { useTicker } from "../lib/use-ticker";
|
||||
|
||||
interface Indicators {
|
||||
@@ -67,10 +72,8 @@ export default function TechnicalPage() {
|
||||
chart = lc.createChart(chartRef.current!, {
|
||||
width: chartRef.current!.clientWidth,
|
||||
height: 400,
|
||||
layout: { background: { color: "#1A1A26" }, textColor: "#9CA3AF" },
|
||||
grid: { vertLines: { color: "#2A2A3A" }, horzLines: { color: "#2A2A3A" } },
|
||||
...lightweightTheme,
|
||||
crosshair: { mode: 0 },
|
||||
timeScale: { borderColor: "#2A2A3A" },
|
||||
});
|
||||
|
||||
// v5 API: use addSeries with series type constructor
|
||||
@@ -80,12 +83,12 @@ export default function TechnicalPage() {
|
||||
if (CandlestickSeries && typeof chart.addSeries === "function") {
|
||||
// v5 path
|
||||
const candlestickSeries = chart.addSeries(CandlestickSeries, {
|
||||
upColor: "#00D4AA",
|
||||
downColor: "#FF4757",
|
||||
borderUpColor: "#00D4AA",
|
||||
borderDownColor: "#FF4757",
|
||||
wickUpColor: "#00D4AA",
|
||||
wickDownColor: "#FF4757",
|
||||
upColor: chartPalette.green,
|
||||
downColor: chartPalette.red,
|
||||
borderUpColor: chartPalette.green,
|
||||
borderDownColor: chartPalette.red,
|
||||
wickUpColor: chartPalette.green,
|
||||
wickDownColor: chartPalette.red,
|
||||
});
|
||||
candlestickSeries.setData(bars);
|
||||
|
||||
@@ -100,18 +103,18 @@ export default function TechnicalPage() {
|
||||
bars.map((b: ChartBar) => ({
|
||||
time: b.time,
|
||||
value: b.volume,
|
||||
color: b.close >= b.open ? "rgba(0,212,170,0.3)" : "rgba(255,71,87,0.3)",
|
||||
color: b.close >= b.open ? "rgba(45,139,94,0.24)" : "rgba(192,57,43,0.24)",
|
||||
}))
|
||||
);
|
||||
} else if (typeof chart.addCandlestickSeries === "function") {
|
||||
// v4 fallback
|
||||
const candlestickSeries = chart.addCandlestickSeries({
|
||||
upColor: "#00D4AA",
|
||||
downColor: "#FF4757",
|
||||
borderUpColor: "#00D4AA",
|
||||
borderDownColor: "#FF4757",
|
||||
wickUpColor: "#00D4AA",
|
||||
wickDownColor: "#FF4757",
|
||||
upColor: chartPalette.green,
|
||||
downColor: chartPalette.red,
|
||||
borderUpColor: chartPalette.green,
|
||||
borderDownColor: chartPalette.red,
|
||||
wickUpColor: chartPalette.green,
|
||||
wickDownColor: chartPalette.red,
|
||||
});
|
||||
candlestickSeries.setData(bars);
|
||||
|
||||
@@ -126,7 +129,7 @@ export default function TechnicalPage() {
|
||||
bars.map((b: ChartBar) => ({
|
||||
time: b.time,
|
||||
value: b.volume,
|
||||
color: b.close >= b.open ? "rgba(0,212,170,0.3)" : "rgba(255,71,87,0.3)",
|
||||
color: b.close >= b.open ? "rgba(45,139,94,0.24)" : "rgba(192,57,43,0.24)",
|
||||
}))
|
||||
);
|
||||
}
|
||||
@@ -147,21 +150,15 @@ export default function TechnicalPage() {
|
||||
};
|
||||
}, [bars]);
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center h-64"><div className="text-accent-green animate-pulse font-mono">Loading...</div></div>;
|
||||
|
||||
const rsiColor = indicators?.rsi_14
|
||||
? indicators.rsi_14 > 70 ? "text-accent-red" : indicators.rsi_14 < 30 ? "text-accent-green" : "text-text-primary"
|
||||
: "text-text-primary";
|
||||
if (loading) return <LoadingPulse label="Loading technical data…" />;
|
||||
|
||||
const macdSignal = indicators?.macd
|
||||
? indicators.macd.histogram > 0 ? "Bullish" : "Bearish"
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">
|
||||
<span className="text-accent-green">{ticker}</span> Technical Analysis
|
||||
</h1>
|
||||
<div className="atlas-page">
|
||||
<SectionHeading level={1}>{ticker} Technical Analysis</SectionHeading>
|
||||
|
||||
{/* Period Selector */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
@@ -169,10 +166,10 @@ export default function TechnicalPage() {
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-mono transition-all ${
|
||||
className={`rounded-md px-3 py-1.5 text-sm font-mono transition-all ${
|
||||
period === p
|
||||
? "bg-accent-green text-bg-primary font-semibold"
|
||||
: "bg-bg-card text-text-secondary hover:bg-bg-card/80"
|
||||
? "bg-brand-navy text-white font-semibold"
|
||||
: "bg-surface-raised text-text-secondary shadow-card hover:bg-surface-sunken"
|
||||
}`}
|
||||
>
|
||||
{p.toUpperCase()}
|
||||
@@ -181,43 +178,34 @@ export default function TechnicalPage() {
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4 mb-6">
|
||||
<ChartContainer title="Candlestick Chart" subtitle="OHLC with volume profile." className="mb-6">
|
||||
<div ref={chartRef} className="w-full" style={{ minHeight: 400 }} />
|
||||
</div>
|
||||
</ChartContainer>
|
||||
|
||||
{/* Indicator Cards */}
|
||||
{indicators && (
|
||||
<div className="grid grid-cols-4 gap-3 mb-6">
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4">
|
||||
<div className="text-text-muted text-xs mb-1">Current Price</div>
|
||||
<div className="text-text-primary font-mono font-bold text-xl">${indicators.current_price?.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4">
|
||||
<div className="text-text-muted text-xs mb-1">RSI (14)</div>
|
||||
<div className={`font-mono font-bold text-xl ${rsiColor}`}>{indicators.rsi_14}</div>
|
||||
<div className="text-text-muted text-xs mt-1">
|
||||
{indicators.rsi_14 > 70 ? "Overbought" : indicators.rsi_14 < 30 ? "Oversold" : "Neutral"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4">
|
||||
<div className="text-text-muted text-xs mb-1">MACD Signal</div>
|
||||
<div className={`font-mono font-bold text-xl ${indicators.macd.histogram > 0 ? "text-accent-green" : "text-accent-red"}`}>
|
||||
{macdSignal}
|
||||
</div>
|
||||
<div className="text-text-muted text-xs mt-1 font-mono">H: {indicators.macd.histogram.toFixed(4)}</div>
|
||||
</div>
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4">
|
||||
<div className="text-text-muted text-xs mb-1">ATR (14)</div>
|
||||
<div className="text-text-primary font-mono font-bold text-xl">{indicators.atr_14}</div>
|
||||
<div className="text-text-muted text-xs mt-1">Volatility</div>
|
||||
</div>
|
||||
<StatCard label="Current Price" value={`$${indicators.current_price?.toFixed(2)}`} />
|
||||
<StatCard
|
||||
label="RSI (14)"
|
||||
value={indicators.rsi_14}
|
||||
tone={indicators.rsi_14 > 70 ? "negative" : indicators.rsi_14 < 30 ? "positive" : "default"}
|
||||
detail={indicators.rsi_14 > 70 ? "Overbought" : indicators.rsi_14 < 30 ? "Oversold" : "Neutral"}
|
||||
/>
|
||||
<StatCard
|
||||
label="MACD Signal"
|
||||
value={macdSignal}
|
||||
tone={indicators.macd.histogram > 0 ? "positive" : "negative"}
|
||||
detail={`H: ${indicators.macd.histogram.toFixed(4)}`}
|
||||
/>
|
||||
<StatCard label="ATR (14)" value={indicators.atr_14} detail="Volatility" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Moving Averages & Bollinger */}
|
||||
{indicators && (
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5">
|
||||
<ChartContainer title="Moving Averages">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">Moving Averages</h3>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
@@ -232,8 +220,8 @@ export default function TechnicalPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-text-primary font-mono">{ma.value != null ? `$${ma.value.toFixed(2)}` : "—"}</span>
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded ${
|
||||
ma.signal === true ? "bg-accent-green/20 text-accent-green" :
|
||||
ma.signal === false ? "bg-accent-red/20 text-accent-red" : "bg-bg-primary text-text-muted"
|
||||
ma.signal === true ? "bg-fin-positive/15 text-fin-positive" :
|
||||
ma.signal === false ? "bg-fin-negative/15 text-fin-negative" : "bg-surface-sunken text-text-muted"
|
||||
}`}>
|
||||
{ma.signal === true ? "ABOVE" : ma.signal === false ? "BELOW" : "N/A"}
|
||||
</span>
|
||||
@@ -241,22 +229,22 @@ export default function TechnicalPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ChartContainer>
|
||||
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5">
|
||||
<ChartContainer title="Bollinger Bands" subtitle="20-period, 2 standard deviations.">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">Bollinger Bands (20, 2)</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-text-muted">Upper Band</span>
|
||||
<span className="text-accent-red font-mono">${indicators.bollinger_bands.upper.toFixed(2)}</span>
|
||||
<span className="font-mono text-fin-negative">${indicators.bollinger_bands.upper.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-text-muted">Middle Band</span>
|
||||
<span className="text-accent-yellow font-mono">${indicators.bollinger_bands.middle.toFixed(2)}</span>
|
||||
<span className="font-mono text-brand-gold">${indicators.bollinger_bands.middle.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-text-muted">Lower Band</span>
|
||||
<span className="text-accent-green font-mono">${indicators.bollinger_bands.lower.toFixed(2)}</span>
|
||||
<span className="font-mono text-fin-positive">${indicators.bollinger_bands.lower.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm border-t border-border pt-3">
|
||||
<span className="text-text-muted">BB Width</span>
|
||||
@@ -285,26 +273,26 @@ export default function TechnicalPage() {
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-text-muted">Histogram</span>
|
||||
<span className={`font-mono ${indicators.macd.histogram > 0 ? "text-accent-green" : "text-accent-red"}`}>
|
||||
<span className={`font-mono ${indicators.macd.histogram > 0 ? "text-fin-positive" : "text-fin-negative"}`}>
|
||||
{indicators.macd.histogram.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fibonacci Levels */}
|
||||
{fib && (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5">
|
||||
<ChartContainer title="Fibonacci Retracement">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">Fibonacci Retracement</h3>
|
||||
<div className="grid grid-cols-7 gap-3">
|
||||
{Object.entries(fib.levels).map(([level, price]) => {
|
||||
const isNear = Math.abs(price - fib.current_price) / fib.current_price < 0.02;
|
||||
return (
|
||||
<div key={level} className={`text-center p-3 rounded-lg ${isNear ? "bg-accent-green/10 border border-accent-green" : "bg-bg-primary"}`}>
|
||||
<div key={level} className={`rounded-lg p-3 text-center ${isNear ? "border border-brand-gold bg-brand-gold/10" : "bg-surface-sunken"}`}>
|
||||
<div className="text-text-muted text-xs mb-1">{level}</div>
|
||||
<div className={`font-mono text-sm font-semibold ${isNear ? "text-accent-green" : "text-text-primary"}`}>
|
||||
<div className={`font-mono text-sm font-semibold ${isNear ? "text-brand-navy" : "text-text-primary"}`}>
|
||||
${price.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -314,7 +302,7 @@ export default function TechnicalPage() {
|
||||
<div className="mt-3 text-text-muted text-xs font-mono">
|
||||
52W Range: ${fib.low_52w.toFixed(2)} — ${fib.high_52w.toFixed(2)} | Current: ${fib.current_price.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ErrorBanner } from "../components/ui/ErrorBanner";
|
||||
import { LoadingPulse } from "../components/ui/LoadingPulse";
|
||||
import { SectionHeading } from "../components/ui/SectionHeading";
|
||||
import { useApi } from "../lib/use-api";
|
||||
import { useTicker } from "../lib/use-ticker";
|
||||
|
||||
interface SmartDefaults {
|
||||
wacc?: number;
|
||||
terminal_growth?: number;
|
||||
fcf_growth?: number;
|
||||
}
|
||||
interface OverviewResp {
|
||||
asset_type?: string;
|
||||
}
|
||||
|
||||
interface DCFInputs {
|
||||
fcf?: number;
|
||||
total_debt?: number;
|
||||
@@ -51,47 +64,59 @@ type ValuationTab = "dcf" | "sensitivity" | "montecarlo" | "tornado" | "reverse"
|
||||
|
||||
export default function ValuationPage() {
|
||||
const { ticker, initialized } = useTicker();
|
||||
const [assetType, setAssetType] = useState<string>("equity");
|
||||
const [inputs, setInputs] = useState<DCFInputs | null>(null);
|
||||
const [consensus, setConsensus] = useState<Consensus | null>(null);
|
||||
const [dcfResult, setDcfResult] = useState<DCFResult | null>(null);
|
||||
const [wacc, setWacc] = useState(10);
|
||||
const [terminalGrowth, setTerminalGrowth] = useState(2.5);
|
||||
const [fcfGrowth, setFcfGrowth] = useState(8);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dcfLoading, setDcfLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<ValuationTab>("dcf");
|
||||
|
||||
// Advanced models state
|
||||
// Advanced models state (user-triggered; still raw fetch — will be split in P1-3)
|
||||
const [dcfResult, setDcfResult] = useState<DCFResult | null>(null);
|
||||
const [sensitivity, setSensitivity] = useState<SensitivityData | null>(null);
|
||||
const [tornado, setTornado] = useState<TornadoItem[]>([]);
|
||||
const [monteCarlo, setMonteCarlo] = useState<MonteCarloData | null>(null);
|
||||
const [reverseDCF, setReverseDCF] = useState<{ implied_growth: number | null; current_price: number | null } | null>(null);
|
||||
const [advLoading, setAdvLoading] = useState(false);
|
||||
const [runtimeNotice, setRuntimeNotice] = useState<string>("");
|
||||
|
||||
// Initial data fetch via useApi — dedupes with /page and /research overview calls.
|
||||
const inputsApi = useApi<DCFInputs>(initialized ? `/api/valuation/dcf-inputs/${ticker}` : null);
|
||||
const consensusApi = useApi<Consensus>(initialized ? `/api/valuation/consensus/${ticker}` : null);
|
||||
const defaultsApi = useApi<SmartDefaults>(initialized ? `/api/valuation/smart-defaults/${ticker}` : null);
|
||||
const overviewApi = useApi<OverviewResp>(initialized ? `/api/market/overview/${ticker}` : null);
|
||||
|
||||
const inputs = inputsApi.data;
|
||||
const consensus = consensusApi.data;
|
||||
const assetType = overviewApi.data?.asset_type || "equity";
|
||||
const loading = !initialized || inputsApi.loading || consensusApi.loading || defaultsApi.loading || overviewApi.loading;
|
||||
|
||||
// Apply smart defaults once loaded.
|
||||
useEffect(() => {
|
||||
const d = defaultsApi.data;
|
||||
if (!d) return;
|
||||
if (d.wacc) setWacc(d.wacc);
|
||||
if (d.terminal_growth) setTerminalGrowth(d.terminal_growth);
|
||||
if (d.fcf_growth) setFcfGrowth(d.fcf_growth);
|
||||
}, [defaultsApi.data]);
|
||||
|
||||
// Reset downstream model results when ticker changes.
|
||||
useEffect(() => {
|
||||
if (!initialized) return;
|
||||
setLoading(true);
|
||||
setDcfResult(null);
|
||||
setSensitivity(null);
|
||||
setTornado([]);
|
||||
setMonteCarlo(null);
|
||||
setReverseDCF(null);
|
||||
Promise.all([
|
||||
fetch(`/api/valuation/dcf-inputs/${ticker}`).then((r) => r.ok ? r.json() : null),
|
||||
fetch(`/api/valuation/consensus/${ticker}`).then((r) => r.ok ? r.json() : null),
|
||||
fetch(`/api/valuation/smart-defaults/${ticker}`).then((r) => r.ok ? r.json() : null),
|
||||
fetch(`/api/market/overview/${ticker}`).then((r) => r.ok ? r.json() : null),
|
||||
]).then(([i, c, d, o]) => {
|
||||
setInputs(i);
|
||||
setConsensus(c);
|
||||
setAssetType(o?.asset_type || "equity");
|
||||
if (d?.wacc) setWacc(d.wacc);
|
||||
if (d?.terminal_growth) setTerminalGrowth(d.terminal_growth);
|
||||
if (d?.fcf_growth) setFcfGrowth(d.fcf_growth);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, [ticker, initialized]);
|
||||
setRuntimeNotice("");
|
||||
}, [ticker]);
|
||||
|
||||
// Derived notice: prioritise runtime errors (DCF failures), then load-time warnings.
|
||||
const loadNotice =
|
||||
runtimeNotice ||
|
||||
(inputsApi.error
|
||||
? "Could not load valuation inputs. Check the backend connection and retry."
|
||||
: !loading && (!inputs?.fcf || !inputs?.shares)
|
||||
? `DCF inputs are incomplete for ${ticker}. Smart defaults applied — review values before running models.`
|
||||
: "");
|
||||
|
||||
async function runDCF() {
|
||||
if (!inputs) return;
|
||||
@@ -111,8 +136,15 @@ export default function ValuationPage() {
|
||||
fcf_growth_rate: fcfGrowth / 100,
|
||||
}),
|
||||
});
|
||||
if (res.ok) setDcfResult(await res.json());
|
||||
} catch { /* */ }
|
||||
if (res.ok) {
|
||||
setDcfResult(await res.json());
|
||||
setRuntimeNotice("");
|
||||
} else {
|
||||
setRuntimeNotice("DCF calculation failed. Verify inputs or retry shortly.");
|
||||
}
|
||||
} catch {
|
||||
setRuntimeNotice("DCF request could not reach the backend.");
|
||||
}
|
||||
setDcfLoading(false);
|
||||
}
|
||||
|
||||
@@ -167,19 +199,20 @@ export default function ValuationPage() {
|
||||
});
|
||||
if (res.ok) setReverseDCF(await res.json());
|
||||
}
|
||||
} catch { /* */ }
|
||||
setRuntimeNotice("");
|
||||
} catch {
|
||||
setRuntimeNotice(`${tab} model request failed. Check the backend and retry.`);
|
||||
}
|
||||
setAdvLoading(false);
|
||||
}
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center h-64"><div className="text-accent-green animate-pulse font-mono">Loading...</div></div>;
|
||||
if (loading) return <LoadingPulse label="Loading..." />;
|
||||
|
||||
if (assetType !== "equity") {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">
|
||||
<span className="text-accent-green">{ticker}</span> Valuation
|
||||
</h1>
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5">
|
||||
<div className="atlas-page">
|
||||
<SectionHeading level={1}>{ticker} Valuation</SectionHeading>
|
||||
<div className="atlas-card p-5">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-3">
|
||||
{assetType === "etf" ? "ETF Valuation Mode" : "Commodity Valuation Mode"}
|
||||
</h3>
|
||||
@@ -194,10 +227,10 @@ export default function ValuationPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">
|
||||
<span className="text-accent-green">{ticker}</span> Valuation
|
||||
</h1>
|
||||
<div className="atlas-page">
|
||||
<SectionHeading level={1}>{ticker} Valuation</SectionHeading>
|
||||
|
||||
<ErrorBanner className="mb-4" variant="info" message={loadNotice || null} />
|
||||
|
||||
{/* Analyst Consensus */}
|
||||
{consensus && (
|
||||
@@ -522,14 +555,14 @@ function SliderInput({ label, value, onChange, min, max, step, suffix }: {
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className="text-text-muted">{label}</span>
|
||||
<span className="text-accent-green font-mono font-semibold">{value}{suffix}</span>
|
||||
<span className="font-mono font-semibold text-brand-navy">{value}{suffix}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={min} max={max} step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value))}
|
||||
className="w-full accent-[#00D4AA]"
|
||||
className="w-full accent-brand-navy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,30 +7,55 @@ const config: Config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
surface: {
|
||||
canvas: "#FAFAFA",
|
||||
raised: "#FFFFFF",
|
||||
sunken: "#F1F3F6",
|
||||
overlay: "#FFFFFF",
|
||||
},
|
||||
brand: {
|
||||
navy: "#1B2A4A",
|
||||
blue: "#2E5B9A",
|
||||
"blue-hover": "#254A80",
|
||||
gold: "#C4A35A",
|
||||
"gold-soft": "#E4D2A6",
|
||||
},
|
||||
fin: {
|
||||
positive: "#2D8B5E",
|
||||
negative: "#C0392B",
|
||||
neutral: "#6B7B8D",
|
||||
warning: "#D9822B",
|
||||
},
|
||||
bg: {
|
||||
primary: "#0A0A0F",
|
||||
secondary: "#12121A",
|
||||
card: "#1A1A26",
|
||||
hover: "#252536",
|
||||
primary: "#FAFAFA",
|
||||
secondary: "#F1F3F6",
|
||||
card: "#FFFFFF",
|
||||
hover: "#F1F3F6",
|
||||
},
|
||||
accent: {
|
||||
green: "#00D4AA",
|
||||
red: "#FF4757",
|
||||
yellow: "#FFD93D",
|
||||
blue: "#4DA6FF",
|
||||
green: "#1B2A4A",
|
||||
red: "#C0392B",
|
||||
yellow: "#C4A35A",
|
||||
blue: "#2E5B9A",
|
||||
},
|
||||
text: {
|
||||
primary: "#F3F4F6",
|
||||
secondary: "#9CA3AF",
|
||||
muted: "#6B7280",
|
||||
primary: "#1A1A2E",
|
||||
secondary: "#4A5568",
|
||||
muted: "#6B7B8D",
|
||||
},
|
||||
border: {
|
||||
DEFAULT: "#2A2A3A",
|
||||
DEFAULT: "#E8ECF0",
|
||||
strong: "#CBD5DF",
|
||||
subtle: "#F1F3F6",
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["Inter", "system-ui", "sans-serif"],
|
||||
mono: ["JetBrains Mono", "monospace"],
|
||||
serif: ["var(--font-serif)", "Georgia", "serif"],
|
||||
sans: ["var(--font-sans)", "system-ui", "sans-serif"],
|
||||
mono: ["var(--font-mono)", "monospace"],
|
||||
},
|
||||
boxShadow: {
|
||||
card: "0 1px 0 #E8ECF0",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -8,6 +8,7 @@ import os
|
||||
import re
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -32,39 +33,58 @@ class SimpleQuestionRequest(BaseModel):
|
||||
api_key: str = ""
|
||||
|
||||
|
||||
def _call_gemini(
|
||||
async def _call_gemini(
|
||||
api_key: str,
|
||||
prompt: str,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
) -> str:
|
||||
"""Call Gemini API directly and return text response."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={api_key}"
|
||||
|
||||
payload = json.dumps({
|
||||
"""Call Gemini API directly (non-blocking) and return text response."""
|
||||
url = (
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/"
|
||||
f"gemini-2.0-flash:generateContent?key={api_key}"
|
||||
)
|
||||
payload = {
|
||||
"contents": [{"parts": [{"text": prompt}]}],
|
||||
"generationConfig": {"maxOutputTokens": max_tokens, "temperature": temperature},
|
||||
}).encode("utf-8")
|
||||
"generationConfig": {
|
||||
"maxOutputTokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
},
|
||||
}
|
||||
|
||||
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
candidates = data.get("candidates", [])
|
||||
if candidates:
|
||||
parts = candidates[0].get("content", {}).get("parts", [])
|
||||
if parts:
|
||||
return parts[0].get("text", "")
|
||||
return "No response from Gemini."
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace")
|
||||
logger.error("Gemini API error %d: %s", e.code, body)
|
||||
raise HTTPException(status_code=e.code, detail=f"Gemini API error: {body[:200]}")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Gemini call failed: {e}")
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
logger.warning("Gemini API timeout: %s", exc)
|
||||
raise HTTPException(status_code=504, detail="Gemini API timeout") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
logger.exception("Gemini API request error")
|
||||
raise HTTPException(status_code=502, detail=f"Gemini request failed: {exc}") from exc
|
||||
|
||||
if resp.status_code >= 400:
|
||||
body = resp.text[:200]
|
||||
logger.error("Gemini API error %d: %s", resp.status_code, body)
|
||||
raise HTTPException(
|
||||
status_code=resp.status_code, detail=f"Gemini API error: {body}"
|
||||
)
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError as exc:
|
||||
logger.exception("Gemini response JSON decode failed")
|
||||
raise HTTPException(status_code=502, detail="Gemini response not JSON") from exc
|
||||
|
||||
candidates = data.get("candidates", [])
|
||||
if candidates:
|
||||
parts = candidates[0].get("content", {}).get("parts", [])
|
||||
if parts:
|
||||
return parts[0].get("text", "")
|
||||
return "No response from Gemini."
|
||||
|
||||
|
||||
def _build_anomaly_filing_context(
|
||||
@@ -197,7 +217,7 @@ Provide a detailed, professional analysis in markdown format. Include:
|
||||
|
||||
Be specific with numbers and data. Answer in the same language as the question."""
|
||||
|
||||
result = _call_gemini(api_key, prompt)
|
||||
result = await _call_gemini(api_key, prompt)
|
||||
return {"ticker": req.ticker.upper(), "analysis": result}
|
||||
|
||||
|
||||
@@ -223,7 +243,7 @@ Provide a detailed risk assessment including:
|
||||
|
||||
Be specific and use the financial data provided. Answer in markdown format."""
|
||||
|
||||
result = _call_gemini(api_key, prompt)
|
||||
result = await _call_gemini(api_key, prompt)
|
||||
return {"ticker": req.ticker.upper(), "analysis": result}
|
||||
|
||||
|
||||
@@ -249,7 +269,7 @@ Provide insights on:
|
||||
|
||||
Use markdown format with headers and bullet points."""
|
||||
|
||||
result = _call_gemini(api_key, prompt)
|
||||
result = await _call_gemini(api_key, prompt)
|
||||
return {"ticker": req.ticker.upper(), "report": result}
|
||||
|
||||
|
||||
@@ -276,7 +296,7 @@ Check for:
|
||||
|
||||
Use markdown format. Be thorough but fair."""
|
||||
|
||||
result = _call_gemini(api_key, prompt)
|
||||
result = await _call_gemini(api_key, prompt)
|
||||
return {"ticker": req.ticker.upper(), "forensic": result}
|
||||
|
||||
|
||||
@@ -288,7 +308,7 @@ async def extract_financials(req: AnalysisRequest):
|
||||
raise HTTPException(status_code=400, detail="API key required.")
|
||||
|
||||
context = _get_financial_context(req.ticker.upper())
|
||||
result = _call_gemini(api_key, f"Summarize the key financial data for analysis:\n\n{context}")
|
||||
result = await _call_gemini(api_key, f"Summarize the key financial data for analysis:\n\n{context}")
|
||||
return {"ticker": req.ticker.upper(), "financials": result}
|
||||
|
||||
|
||||
@@ -360,7 +380,7 @@ Output rules:
|
||||
|
||||
raw = ""
|
||||
try:
|
||||
raw = _call_gemini(api_key, prompt, max_tokens=2048, temperature=0.2)
|
||||
raw = await _call_gemini(api_key, prompt, max_tokens=2048, temperature=0.2)
|
||||
parsed = _parse_llm_json_object(raw)
|
||||
return _anomaly_response_from_parsed(parsed)
|
||||
except json.JSONDecodeError:
|
||||
@@ -420,7 +440,7 @@ async def translate_text(req: TranslateRequest):
|
||||
)
|
||||
|
||||
try:
|
||||
result = _call_gemini(api_key, prompt, max_tokens=8192, temperature=0.2)
|
||||
result = await _call_gemini(api_key, prompt, max_tokens=8192, temperature=0.2)
|
||||
return {"translated_text": result}
|
||||
except Exception as exc:
|
||||
logger.exception("Translation failed")
|
||||
@@ -485,7 +505,7 @@ async def institutional_analysis(req: InstitutionalRequest):
|
||||
prompt += "\n\nIMPORTANT: Write the entire analysis in Japanese (日本語). Keep financial terms in English."
|
||||
|
||||
try:
|
||||
raw = _call_gemini(api_key, prompt, max_tokens=8192, temperature=0.3)
|
||||
raw = await _call_gemini(api_key, prompt, max_tokens=8192, temperature=0.3)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
"""Crypto router -- live cryptocurrency prices from Bithumb (KRW) and Binance (USD)."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from server.models.schemas import CryptoPrice
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Top 20 symbols tracked by default
|
||||
TOP_SYMBOLS = [
|
||||
@@ -22,19 +26,17 @@ _BITHUMB_MAP = {
|
||||
}
|
||||
|
||||
|
||||
def _fetch_binance_prices(symbols: List[str]) -> dict:
|
||||
async def _fetch_binance_prices(client: httpx.AsyncClient, symbols: List[str]) -> dict:
|
||||
"""Fetch USD prices from Binance API for the given symbols."""
|
||||
import requests
|
||||
|
||||
url = "https://api.binance.com/api/v3/ticker/price"
|
||||
try:
|
||||
resp = requests.get(url, timeout=10)
|
||||
resp = await client.get(url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
logger.warning("Binance prices fetch failed: %s", exc)
|
||||
return {}
|
||||
|
||||
# Build lookup: symbol (no USDT suffix) -> price
|
||||
prices = {}
|
||||
lookup = {item["symbol"]: float(item["price"]) for item in data}
|
||||
for sym in symbols:
|
||||
@@ -44,37 +46,40 @@ def _fetch_binance_prices(symbols: List[str]) -> dict:
|
||||
return prices
|
||||
|
||||
|
||||
def _fetch_bithumb_prices(symbols: List[str]) -> dict:
|
||||
"""Fetch KRW prices from Bithumb public API."""
|
||||
import requests
|
||||
|
||||
prices = {}
|
||||
for sym in symbols:
|
||||
async def _fetch_bithumb_prices(client: httpx.AsyncClient, symbols: List[str]) -> dict:
|
||||
"""Fetch KRW prices from Bithumb public API (parallel)."""
|
||||
async def _one(sym: str):
|
||||
bithumb_sym = _BITHUMB_MAP.get(sym.upper(), sym.upper())
|
||||
url = f"https://api.bithumb.com/public/ticker/{bithumb_sym}_KRW"
|
||||
try:
|
||||
resp = requests.get(url, timeout=5)
|
||||
resp = await client.get(url, timeout=5)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("status") == "0000":
|
||||
closing = data.get("data", {}).get("closing_price")
|
||||
if closing:
|
||||
prices[sym.upper()] = float(closing)
|
||||
except Exception:
|
||||
continue
|
||||
return sym.upper(), float(closing)
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
logger.debug("Bithumb %s failed: %s", sym, exc)
|
||||
return None
|
||||
|
||||
results = await asyncio.gather(*[_one(s) for s in symbols], return_exceptions=True)
|
||||
prices = {}
|
||||
for r in results:
|
||||
if isinstance(r, tuple):
|
||||
prices[r[0]] = r[1]
|
||||
return prices
|
||||
|
||||
|
||||
def _fetch_binance_24h_changes(symbols: List[str]) -> dict:
|
||||
async def _fetch_binance_24h_changes(client: httpx.AsyncClient, symbols: List[str]) -> dict:
|
||||
"""Fetch 24h percentage changes from Binance."""
|
||||
import requests
|
||||
|
||||
url = "https://api.binance.com/api/v3/ticker/24hr"
|
||||
try:
|
||||
resp = requests.get(url, timeout=10)
|
||||
resp = await client.get(url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
logger.warning("Binance 24h changes fetch failed: %s", exc)
|
||||
return {}
|
||||
|
||||
changes = {}
|
||||
@@ -97,9 +102,12 @@ async def crypto_prices():
|
||||
USD prices come from Binance; KRW prices from Bithumb.
|
||||
"""
|
||||
try:
|
||||
usd_prices = _fetch_binance_prices(TOP_SYMBOLS)
|
||||
krw_prices = _fetch_bithumb_prices(TOP_SYMBOLS)
|
||||
changes = _fetch_binance_24h_changes(TOP_SYMBOLS)
|
||||
async with httpx.AsyncClient() as client:
|
||||
usd_prices, krw_prices, changes = await asyncio.gather(
|
||||
_fetch_binance_prices(client, TOP_SYMBOLS),
|
||||
_fetch_bithumb_prices(client, TOP_SYMBOLS),
|
||||
_fetch_binance_24h_changes(client, TOP_SYMBOLS),
|
||||
)
|
||||
|
||||
results: List[CryptoPrice] = []
|
||||
for sym in TOP_SYMBOLS:
|
||||
@@ -112,6 +120,7 @@ async def crypto_prices():
|
||||
))
|
||||
return results
|
||||
except Exception as exc:
|
||||
logger.exception("Crypto prices endpoint failed")
|
||||
raise HTTPException(status_code=500, detail=f"Crypto prices failed: {exc}") from exc
|
||||
|
||||
|
||||
@@ -124,9 +133,12 @@ async def crypto_price(symbol: str):
|
||||
"""Return current price for a single cryptocurrency symbol."""
|
||||
try:
|
||||
sym = symbol.upper()
|
||||
usd_prices = _fetch_binance_prices([sym])
|
||||
krw_prices = _fetch_bithumb_prices([sym])
|
||||
changes = _fetch_binance_24h_changes([sym])
|
||||
async with httpx.AsyncClient() as client:
|
||||
usd_prices, krw_prices, changes = await asyncio.gather(
|
||||
_fetch_binance_prices(client, [sym]),
|
||||
_fetch_bithumb_prices(client, [sym]),
|
||||
_fetch_binance_24h_changes(client, [sym]),
|
||||
)
|
||||
|
||||
return CryptoPrice(
|
||||
symbol=sym,
|
||||
@@ -136,4 +148,5 @@ async def crypto_price(symbol: str):
|
||||
change_24h_pct=changes.get(sym),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Crypto price endpoint failed")
|
||||
raise HTTPException(status_code=500, detail=f"Crypto price failed: {exc}") from exc
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""EDINET Japan — optional annual report (有価証券報告書) + link fallbacks."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from server.models.schemas import EdgarSectionsResponse
|
||||
@@ -10,11 +13,12 @@ from server.services.edinet_filing_service import (
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/links/{ticker}", summary="EDINET portal links (no API key required)")
|
||||
async def edinet_links(ticker: str):
|
||||
return get_edinet_links(ticker)
|
||||
return await asyncio.to_thread(get_edinet_links, ticker)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -30,12 +34,15 @@ async def edinet_sections(
|
||||
),
|
||||
):
|
||||
try:
|
||||
sections, status, html_frag, meta = get_edinet_sections(ticker)
|
||||
sections, status, html_frag, meta = await asyncio.to_thread(
|
||||
get_edinet_sections, ticker
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
logger.exception("EDINET sections failed for %s", ticker)
|
||||
raise HTTPException(status_code=500, detail=f"EDINET failed: {exc}") from exc
|
||||
|
||||
links = meta.get("links") if isinstance(meta.get("links"), dict) else None
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_df_to_dict(df: Any) -> List[Dict[str, Any]]:
|
||||
@@ -19,6 +21,7 @@ def _safe_df_to_dict(df: Any) -> List[Dict[str, Any]]:
|
||||
return []
|
||||
return df.fillna(0).reset_index().to_dict(orient="records")
|
||||
except Exception:
|
||||
logger.exception("estimates endpoint failed")
|
||||
return []
|
||||
|
||||
|
||||
@@ -84,6 +87,7 @@ async def full_estimates(ticker: str) -> Dict[str, Any]:
|
||||
"price_targets": price_targets,
|
||||
}
|
||||
except Exception:
|
||||
logger.exception("estimates endpoint failed")
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"earnings_estimate": [],
|
||||
@@ -120,6 +124,7 @@ async def earnings_dates(ticker: str) -> Dict[str, Any]:
|
||||
"earnings_history": history_records,
|
||||
}
|
||||
except Exception:
|
||||
logger.exception("estimates endpoint failed")
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"earnings_dates": [],
|
||||
@@ -152,6 +157,7 @@ async def growth_estimates(ticker: str) -> Dict[str, Any]:
|
||||
"eps_trend": eps_records,
|
||||
}
|
||||
except Exception:
|
||||
logger.exception("estimates endpoint failed")
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"growth_estimates": [],
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Financial statements router -- statements, highlights, and ratios."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _df_to_periods(df: Any, max_periods: int = 5) -> List[Dict[str, Any]]:
|
||||
@@ -130,6 +132,7 @@ async def financial_statements(ticker: str) -> Dict[str, Any]:
|
||||
"revenue_yoy_growth": revenue_growth,
|
||||
}
|
||||
except Exception:
|
||||
logger.exception("financials endpoint failed")
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"income_statement": [],
|
||||
@@ -173,6 +176,7 @@ async def financial_highlights(ticker: str) -> Dict[str, Any]:
|
||||
"book_value": _safe_get(info, "bookValue"),
|
||||
"earnings_growth": _safe_get(info, "earningsGrowth"),
|
||||
"revenue_growth": _safe_get(info, "revenueGrowth"),
|
||||
"currency": info.get("currency") or info.get("financialCurrency") or "USD",
|
||||
}
|
||||
|
||||
# Derive EBITDA margin if both values exist
|
||||
@@ -183,6 +187,7 @@ async def financial_highlights(ticker: str) -> Dict[str, Any]:
|
||||
|
||||
return highlights
|
||||
except Exception:
|
||||
logger.exception("financials endpoint failed")
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"company_name": "",
|
||||
@@ -207,6 +212,7 @@ async def kpi_history(ticker: str) -> Dict[str, Any]:
|
||||
|
||||
return build_kpi_history(ticker)
|
||||
except Exception:
|
||||
logger.exception("financials endpoint failed")
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"quarters": [],
|
||||
@@ -270,6 +276,7 @@ async def financial_ratios(ticker: str) -> Dict[str, Any]:
|
||||
|
||||
return ratios
|
||||
except Exception:
|
||||
logger.exception("financials endpoint failed")
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"trailing_pe": None, "forward_pe": None,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from functools import partial
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
@@ -13,60 +14,74 @@ from server.services.smart_money_service import get_smart_money_snapshot
|
||||
from server.services.yield_fx_service import get_yield_fx_pair
|
||||
|
||||
router = APIRouter()
|
||||
_MACRO_TIMEOUT_SECONDS = 8.0
|
||||
|
||||
|
||||
async def _run_macro_task(
|
||||
func: Any,
|
||||
fallback: Dict[str, Any],
|
||||
*args: Any,
|
||||
timeout: float = _MACRO_TIMEOUT_SECONDS,
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
call = partial(func, *args)
|
||||
return await asyncio.wait_for(asyncio.to_thread(call), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
return {**fallback, "error": "upstream_timeout"}
|
||||
except Exception as exc:
|
||||
return {**fallback, "error": str(exc)}
|
||||
|
||||
|
||||
@router.get("/quadrant", summary="Global growth vs inflation quadrant (Z-scores)")
|
||||
async def macro_quadrant() -> Dict[str, Any]:
|
||||
try:
|
||||
return await asyncio.to_thread(get_global_macro_quadrant)
|
||||
except Exception as exc:
|
||||
return {"updated_at": None, "points": [], "error": str(exc)}
|
||||
return await _run_macro_task(
|
||||
get_global_macro_quadrant,
|
||||
{"updated_at": None, "points": []},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/yield-fx", summary="US10Y spread vs FX (usdjpy|eurusd|usdkrw)")
|
||||
async def macro_yield_fx(
|
||||
pair: str = Query("usdjpy", description="usdjpy, eurusd, or usdkrw"),
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
return await asyncio.to_thread(get_yield_fx_pair, pair)
|
||||
except Exception as exc:
|
||||
return {
|
||||
return await _run_macro_task(
|
||||
get_yield_fx_pair,
|
||||
{
|
||||
"pair": pair,
|
||||
"updated_at": None,
|
||||
"series": [],
|
||||
"error": str(exc),
|
||||
}
|
||||
},
|
||||
pair,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/smart-money", summary="Copper/Gold + RORO composite")
|
||||
async def macro_smart_money() -> Dict[str, Any]:
|
||||
try:
|
||||
return await asyncio.to_thread(get_smart_money_snapshot)
|
||||
except Exception as exc:
|
||||
return {
|
||||
return await _run_macro_task(
|
||||
get_smart_money_snapshot,
|
||||
{
|
||||
"updated_at": None,
|
||||
"roro_z": None,
|
||||
"roro_label": None,
|
||||
"copper_gold": [],
|
||||
"components": {},
|
||||
"error": str(exc),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/subfactors", summary="4-category macro subfactor breakdown + cycle stage")
|
||||
async def macro_subfactors() -> Dict[str, Any]:
|
||||
from server.services.macro_cycle import get_subfactor_breakdown
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(get_subfactor_breakdown)
|
||||
except Exception as exc:
|
||||
return {
|
||||
return await _run_macro_task(
|
||||
get_subfactor_breakdown,
|
||||
{
|
||||
"updated_at": None,
|
||||
"composite_score": 0.0,
|
||||
"cycle_stage": "Unknown",
|
||||
"categories": {},
|
||||
"error": str(exc),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/fred/{series_id}", summary="FRED time series (public CSV)")
|
||||
@@ -75,12 +90,19 @@ async def macro_fred(
|
||||
start: Optional[str] = Query(None, description="YYYY-MM-DD"),
|
||||
end: Optional[str] = Query(None, description="YYYY-MM-DD"),
|
||||
) -> Dict[str, Any]:
|
||||
rows = await asyncio.to_thread(
|
||||
macro_fetcher.fetch_fred_series,
|
||||
series_id,
|
||||
start,
|
||||
end,
|
||||
)
|
||||
try:
|
||||
rows = await asyncio.wait_for(
|
||||
asyncio.to_thread(
|
||||
macro_fetcher.fetch_fred_series,
|
||||
series_id,
|
||||
start,
|
||||
end,
|
||||
),
|
||||
timeout=_MACRO_TIMEOUT_SECONDS,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
rows = []
|
||||
return {"series": series_id.strip().upper(), "count": 0, "data": rows, "error": "upstream_timeout"}
|
||||
return {"series": series_id.strip().upper(), "count": len(rows), "data": rows}
|
||||
|
||||
|
||||
@@ -94,28 +116,27 @@ async def macro_oecd_mei() -> Dict[str, Any]:
|
||||
async def macro_oecd_cli() -> Dict[str, Any]:
|
||||
from server.services.oecd_cycle import get_oecd_cli_snapshot
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(get_oecd_cli_snapshot)
|
||||
except Exception as exc:
|
||||
return {"updated_at": None, "countries": [], "error": str(exc)}
|
||||
return await _run_macro_task(
|
||||
get_oecd_cli_snapshot,
|
||||
{"updated_at": None, "countries": []},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/snapshot", summary="Macro cycle heatmap, countries, asset valuation")
|
||||
async def macro_snapshot() -> Dict[str, Any]:
|
||||
from server.services.macro_cycle import get_macro_cycle_snapshot
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(get_macro_cycle_snapshot)
|
||||
except Exception as exc:
|
||||
return {
|
||||
return await _run_macro_task(
|
||||
get_macro_cycle_snapshot,
|
||||
{
|
||||
"updated_at": None,
|
||||
"cycle_score": 0.0,
|
||||
"regime": "Unknown",
|
||||
"cycle_heatmap": [],
|
||||
"country_heatmap": [],
|
||||
"asset_valuation": [],
|
||||
"error": str(exc),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/korea", summary="Korea indicators (ECOS or yfinance fallback)")
|
||||
@@ -134,10 +155,11 @@ async def macro_economic_calendar(
|
||||
) -> Dict[str, Any]:
|
||||
from server.services.economic_calendar import get_economic_calendar
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(get_economic_calendar, days)
|
||||
except Exception as exc:
|
||||
return {"events": [], "next_high_impact": None, "total": 0, "error": str(exc)}
|
||||
return await _run_macro_task(
|
||||
get_economic_calendar,
|
||||
{"events": [], "next_high_impact": None, "total": 0},
|
||||
days,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ecos", summary="Korea Bank ECOS (requires ECOS_API_KEY)")
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Market Data router -- sector info, financial trends, comps, health metrics."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from server.utils.ticker_utils import AssetType, detect_asset_type
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_float(val, default=0.0):
|
||||
@@ -45,10 +47,12 @@ async def market_indices():
|
||||
"change": f"{pct:+.2f}%",
|
||||
"positive": pct >= 0,
|
||||
})
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.warning("indices: fetch %s failed: %s", s["symbol"], exc)
|
||||
results.append({"label": s["label"], "symbol": s["symbol"], "price": "—", "change": "—", "positive": True})
|
||||
return results
|
||||
except Exception:
|
||||
logger.exception("indices: outer failure")
|
||||
return []
|
||||
|
||||
|
||||
@@ -59,6 +63,7 @@ async def market_overview():
|
||||
|
||||
return await get_market_overview()
|
||||
except Exception as e:
|
||||
logger.exception("market_overview failed")
|
||||
return {"error": str(e), "data": None}
|
||||
|
||||
|
||||
@@ -69,6 +74,7 @@ async def sector_heatmap():
|
||||
|
||||
return await get_sector_heatmap()
|
||||
except Exception as e:
|
||||
logger.exception("sector_heatmap failed")
|
||||
return {"error": str(e), "data": None}
|
||||
|
||||
|
||||
@@ -93,6 +99,7 @@ async def market_overview_by_ticker(ticker: str):
|
||||
|
||||
return {"asset_type": AssetType.EQUITY.value, "data": await get_equity_overview(ticker)}
|
||||
except Exception as e:
|
||||
logger.exception("overview/%s failed", ticker)
|
||||
return {"error": str(e), "asset_type": AssetType.EQUITY.value, "data": None}
|
||||
|
||||
|
||||
@@ -103,6 +110,7 @@ async def etf_holdings(ticker: str):
|
||||
|
||||
return {"ticker": ticker.upper(), "holdings": await get_etf_holdings(ticker)}
|
||||
except Exception as e:
|
||||
logger.exception("etf/%s/holdings failed", ticker)
|
||||
return {"error": str(e), "ticker": ticker.upper(), "holdings": []}
|
||||
|
||||
|
||||
@@ -114,6 +122,7 @@ async def commodity_seasonal(ticker: str):
|
||||
data = await get_commodity_overview(ticker)
|
||||
return {"ticker": ticker.upper(), "seasonal_pattern": data.get("seasonal_pattern", {})}
|
||||
except Exception as e:
|
||||
logger.exception("commodity/%s/seasonal failed", ticker)
|
||||
return {"error": str(e), "ticker": ticker.upper(), "seasonal_pattern": {}}
|
||||
|
||||
|
||||
@@ -124,6 +133,7 @@ async def commodity_correlations(ticker: str):
|
||||
|
||||
return {"ticker": ticker.upper(), "correlations": await compute_commodity_correlations(ticker)}
|
||||
except Exception as e:
|
||||
logger.exception("commodity/%s/correlations failed", ticker)
|
||||
return {"error": str(e), "ticker": ticker.upper(), "correlations": {}}
|
||||
|
||||
|
||||
@@ -164,8 +174,11 @@ async def sector_industry(ticker: str):
|
||||
"website": info.get("website"),
|
||||
"ipo_date": info.get("ipoExpectedDate") or info.get("firstTradeDateEpochUtc"),
|
||||
"description": info.get("longBusinessSummary"),
|
||||
"currency": info.get("currency") or info.get("financialCurrency") or "USD",
|
||||
"exchange": info.get("exchange"),
|
||||
}
|
||||
except Exception:
|
||||
logger.exception("sector/%s failed", ticker)
|
||||
return {"sector": "N/A", "industry": "N/A"}
|
||||
|
||||
|
||||
@@ -201,6 +214,7 @@ async def financial_trend(ticker: str):
|
||||
|
||||
return {"years": years, "revenue": revenue, "net_income": net_income, "operating_margin": op_margin, "fcf": fcf_list}
|
||||
except Exception:
|
||||
logger.exception("trend/%s failed", ticker)
|
||||
return {"years": [], "revenue": [], "net_income": [], "operating_margin": [], "fcf": []}
|
||||
|
||||
|
||||
@@ -212,6 +226,7 @@ async def peer_valuation_multiples(ticker: str):
|
||||
|
||||
return build_peer_comparison(ticker)
|
||||
except Exception:
|
||||
logger.exception("peers/%s failed", ticker)
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"sector": "—",
|
||||
@@ -243,6 +258,7 @@ async def industry_comps(tickers: str = Query(..., description="Comma-separated
|
||||
})
|
||||
return {"tickers": ticker_list, "data": results}
|
||||
except Exception:
|
||||
logger.exception("comps failed for %s", tickers)
|
||||
return {"tickers": [], "data": []}
|
||||
|
||||
|
||||
@@ -376,7 +392,8 @@ async def financial_health(ticker: str):
|
||||
"debt_to_equity": round(debt_to_equity, 2) if debt_to_equity is not None else None,
|
||||
"red_flags": red_flags,
|
||||
}
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
logger.exception("health/%s failed", ticker)
|
||||
return fallback
|
||||
|
||||
|
||||
@@ -455,6 +472,7 @@ async def piotroski_score(ticker: str):
|
||||
|
||||
return {"total": score, "details": details, "score": score}
|
||||
except Exception:
|
||||
logger.exception("piotroski/%s failed", ticker)
|
||||
return {"total": 0, "details": {}, "score": 0}
|
||||
|
||||
|
||||
@@ -477,6 +495,7 @@ async def quick_quote(ticker: str):
|
||||
"change_pct": round(ch, 2) if ch is not None else None,
|
||||
}
|
||||
except Exception:
|
||||
logger.exception("quote/%s failed", ticker)
|
||||
return {"ticker": ticker.upper(), "current_price": None, "change_pct": None}
|
||||
|
||||
|
||||
@@ -512,9 +531,11 @@ async def sankey_data(ticker: str):
|
||||
|
||||
nivo = sankey_nivo_for_ticker(ticker)
|
||||
except Exception:
|
||||
logger.warning("sankey/%s nivo fallback used", ticker)
|
||||
nivo = {"nodes": [], "links": []}
|
||||
return {"nodes": nodes, "nivo": nivo}
|
||||
except Exception:
|
||||
logger.exception("sankey/%s failed", ticker)
|
||||
return {"nodes": [], "nivo": {"nodes": [], "links": []}}
|
||||
|
||||
|
||||
@@ -533,4 +554,5 @@ async def radar_metrics(ticker: str):
|
||||
"revenue_growth": _safe_float(info.get("revenueGrowth", 0)) * 100,
|
||||
}
|
||||
except Exception:
|
||||
logger.exception("radar/%s failed", ticker)
|
||||
return {}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""News router -- Finviz scrape + Google News RSS + Yahoo Finance RSS."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
@@ -8,6 +10,7 @@ from server.models.schemas import NewsItem
|
||||
from server.services.news_aggregator import merge_news_for_router
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -18,9 +21,12 @@ router = APIRouter()
|
||||
async def get_news(ticker: str):
|
||||
"""Return recent articles: Finviz table, Google News RSS, Yahoo headline RSS."""
|
||||
try:
|
||||
merged = merge_news_for_router(ticker.upper(), max_articles=40)
|
||||
merged = await asyncio.to_thread(
|
||||
merge_news_for_router, ticker.upper(), 40
|
||||
)
|
||||
return [NewsItem(**item) for item in merged]
|
||||
except Exception as exc:
|
||||
logger.exception("News fetch failed for %s", ticker)
|
||||
raise HTTPException(status_code=500, detail=f"News fetch failed: {exc}") from exc
|
||||
|
||||
|
||||
@@ -34,7 +40,9 @@ async def ai_news_summary(
|
||||
):
|
||||
"""Fetch merged news and optionally summarize with Gemini."""
|
||||
try:
|
||||
all_items = merge_news_for_router(ticker.upper(), max_articles=30)
|
||||
all_items = await asyncio.to_thread(
|
||||
merge_news_for_router, ticker.upper(), 30
|
||||
)
|
||||
headlines: List[str] = []
|
||||
for item in all_items:
|
||||
title = (item.get("title") or "").strip()
|
||||
@@ -61,7 +69,9 @@ Provide a concise 3-5 sentence executive summary of the overall sentiment and ke
|
||||
|
||||
Headlines:
|
||||
{headline_text}"""
|
||||
response = _generate_with_retry(model, prompt, {"temperature": 0.2, "max_output_tokens": 512})
|
||||
response = await asyncio.to_thread(
|
||||
_generate_with_retry, model, prompt, {"temperature": 0.2, "max_output_tokens": 512}
|
||||
)
|
||||
summary = (response.text or "").strip() if response else ""
|
||||
|
||||
return {
|
||||
@@ -71,4 +81,5 @@ Headlines:
|
||||
"items": all_items,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.exception("AI news summary failed for %s", ticker)
|
||||
raise HTTPException(status_code=500, detail=f"AI news summary failed: {exc}") from exc
|
||||
|
||||
@@ -6,6 +6,7 @@ import uuid
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Header
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -16,6 +17,7 @@ from server.models.schemas import (
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Simple file-based persistence (production would use Supabase / Postgres)
|
||||
_PORTFOLIO_FILE = Path(__file__).resolve().parent.parent.parent / "data" / "portfolio.json"
|
||||
@@ -41,6 +43,7 @@ def _load_positions() -> List[dict]:
|
||||
with open(_PORTFOLIO_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
logger.exception("portfolio endpoint failed")
|
||||
return []
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from server.services.research_dashboard import build_research_dashboard
|
||||
@@ -12,4 +14,5 @@ router = APIRouter()
|
||||
@router.get("/dashboard/{ticker}", summary="F-Score history, DuPont tree, Sankey, waterfall, anomalies")
|
||||
async def research_dashboard(ticker: str):
|
||||
"""Quant-only payload for Research page widgets."""
|
||||
return build_research_dashboard(ticker).model_dump()
|
||||
dashboard = await asyncio.to_thread(build_research_dashboard, ticker)
|
||||
return dashboard.model_dump()
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.post("/search")
|
||||
@@ -15,6 +17,7 @@ async def search_stocks(filters: dict):
|
||||
|
||||
return await run_screener(filters)
|
||||
except Exception as e:
|
||||
logger.exception("screener endpoint failed")
|
||||
return {"error": str(e), "data": []}
|
||||
|
||||
|
||||
@@ -34,6 +37,7 @@ async def backtest(body: dict):
|
||||
rebalance_months=body.get("rebalance_months"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("screener endpoint failed")
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@@ -59,4 +63,5 @@ async def portfolio_backtest(body: dict):
|
||||
benchmark_ticker=str(body.get("benchmark_ticker") or "SPY"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("screener endpoint failed")
|
||||
return {"error": str(e)}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Valuation router -- DCF calculation, smart defaults, analyst consensus,
|
||||
sensitivity analysis, Monte Carlo simulation, reverse DCF, and tornado charts."""
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_float(val, default=0.0):
|
||||
@@ -55,6 +57,7 @@ async def dcf_inputs(ticker: str):
|
||||
|
||||
return {"fcf": fcf, "total_debt": total_debt, "cash": cash, "shares": shares}
|
||||
except Exception:
|
||||
logger.exception("dcf-inputs fallback")
|
||||
return {"fcf": None, "total_debt": 0, "cash": 0, "shares": None}
|
||||
|
||||
|
||||
@@ -118,6 +121,7 @@ async def calculate_dcf(inputs: DCFInputsBody):
|
||||
},
|
||||
}
|
||||
except Exception:
|
||||
logger.exception("calculate_dcf failed")
|
||||
return {"base": None, "bull": None, "bear": None, "current_price": None}
|
||||
|
||||
|
||||
@@ -147,6 +151,7 @@ async def smart_defaults(ticker: str):
|
||||
"sector": sector, "industry": industry,
|
||||
}
|
||||
except Exception:
|
||||
logger.exception("smart_defaults/%s failed", ticker)
|
||||
return {"wacc": 9, "terminal_growth": 2.5, "fcf_growth": 10, "sector": "N/A", "industry": "N/A"}
|
||||
|
||||
|
||||
@@ -195,6 +200,7 @@ async def sensitivity_analysis(body: SensitivityBody):
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
logger.exception("valuation endpoint failed")
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
@@ -209,6 +215,7 @@ async def tornado_chart(body: SensitivityBody):
|
||||
)
|
||||
return {"data": result}
|
||||
except Exception as exc:
|
||||
logger.exception("valuation endpoint failed")
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
@@ -245,6 +252,7 @@ async def monte_carlo_dcf(body: MonteCarloBody):
|
||||
result["values"] = []
|
||||
return result
|
||||
except Exception as exc:
|
||||
logger.exception("valuation endpoint failed")
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
@@ -276,6 +284,7 @@ async def reverse_dcf_endpoint(body: ReverseDCFBody):
|
||||
"current_price": current_price,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.exception("valuation endpoint failed")
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
@@ -295,4 +304,5 @@ async def analyst_consensus(ticker: str):
|
||||
"num_analysts": info.get("numberOfAnalystOpinions", 0),
|
||||
}
|
||||
except Exception:
|
||||
logger.exception("consensus/%s failed", ticker)
|
||||
return {"target_mean": None, "target_high": None, "target_low": None, "target_median": None, "recommendation": "N/A", "num_analysts": 0}
|
||||
|
||||
@@ -147,6 +147,24 @@ async def get_equity_overview(ticker: str) -> dict:
|
||||
|
||||
t = yf.Ticker(ticker.upper())
|
||||
info = t.info or {}
|
||||
|
||||
# Fallback: derive 52W low/high from history when yfinance returns 0 or None
|
||||
# (common for some Asian tickers like 005930.KS where info.fiftyTwoWeekLow == 0)
|
||||
hi52 = _safe_num(info.get("fiftyTwoWeekHigh"))
|
||||
lo52 = _safe_num(info.get("fiftyTwoWeekLow"))
|
||||
if not hi52 or not lo52 or lo52 == 0:
|
||||
try:
|
||||
hist = t.history(period="1y")
|
||||
if hist is not None and len(hist) > 0:
|
||||
h_max = float(hist["High"].max())
|
||||
l_min = float(hist["Low"].min())
|
||||
if not hi52:
|
||||
hi52 = h_max
|
||||
if not lo52 or lo52 == 0:
|
||||
lo52 = l_min
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"name": info.get("longName") or info.get("shortName", ticker.upper()),
|
||||
"sector": info.get("sector"),
|
||||
@@ -155,8 +173,25 @@ async def get_equity_overview(ticker: str) -> dict:
|
||||
"pe_ratio": _safe_num(info.get("trailingPE")) or _safe_num(info.get("forwardPE")),
|
||||
"dividend_yield": _safe_num(info.get("dividendYield")),
|
||||
"beta": _safe_num(info.get("beta")),
|
||||
"high_52w": _safe_num(info.get("fiftyTwoWeekHigh")),
|
||||
"low_52w": _safe_num(info.get("fiftyTwoWeekLow")),
|
||||
"high_52w": hi52,
|
||||
"low_52w": lo52,
|
||||
"price": _safe_num(info.get("currentPrice") or info.get("regularMarketPrice")),
|
||||
"description": info.get("longBusinessSummary"),
|
||||
"currency": info.get("currency") or info.get("financialCurrency") or "USD",
|
||||
"exchange": info.get("exchange"),
|
||||
"country": info.get("country"),
|
||||
"full_time_employees": info.get("fullTimeEmployees"),
|
||||
"average_volume": _safe_num(info.get("averageVolume")),
|
||||
"trailing_pe": _safe_num(info.get("trailingPE")),
|
||||
"forward_pe": _safe_num(info.get("forwardPE")),
|
||||
"enterprise_to_ebitda": _safe_num(info.get("enterpriseToEbitda")),
|
||||
"debt_to_equity": _safe_num(info.get("debtToEquity")),
|
||||
"return_on_equity": _safe_num(info.get("returnOnEquity")),
|
||||
"return_on_assets": _safe_num(info.get("returnOnAssets")),
|
||||
"free_cashflow": _safe_num(info.get("freeCashflow")),
|
||||
"revenue_growth": _safe_num(info.get("revenueGrowth")),
|
||||
"profit_margins": _safe_num(info.get("profitMargins")),
|
||||
"target_mean_price": _safe_num(info.get("targetMeanPrice")),
|
||||
"recommendation": info.get("recommendationKey"),
|
||||
"num_analysts": info.get("numberOfAnalystOpinions"),
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ def _fetch_fred_series(fred_code: str):
|
||||
return web.DataReader(fred_code, "fred", start=start).dropna()
|
||||
except Exception:
|
||||
url = f"https://fred.stlouisfed.org/graph/fredgraph.csv?id={fred_code}"
|
||||
response = requests.get(url, timeout=20)
|
||||
response = requests.get(url, timeout=8)
|
||||
response.raise_for_status()
|
||||
df = pd.read_csv(StringIO(response.text))
|
||||
date_column = "DATE" if "DATE" in df.columns else "observation_date"
|
||||
@@ -222,7 +222,7 @@ def _fetch_worldbank_latest(country_code: str, indicator: str) -> Optional[float
|
||||
f"https://api.worldbank.org/v2/country/{country_code}"
|
||||
f"/indicator/{indicator}?format=json&per_page=5&mrv=3"
|
||||
)
|
||||
resp = requests.get(url, timeout=12)
|
||||
resp = requests.get(url, timeout=6)
|
||||
data = resp.json()
|
||||
if len(data) > 1 and data[1]:
|
||||
for item in data[1]:
|
||||
|
||||
@@ -44,7 +44,7 @@ def fetch_fred_series(
|
||||
url = f"{_FRED_CSV}?id={sid}"
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": _USER_AGENT})
|
||||
with urlopen(req, timeout=45) as resp:
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
text = resp.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
return []
|
||||
@@ -104,7 +104,7 @@ def fetch_ecos_series(
|
||||
url = "https://ecos.bok.or.kr" + path
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@@ -5,14 +5,14 @@ All quantitative; no LLM. See claude.md hybrid separation principle.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from server.utils.safe_float import _safe_float
|
||||
from server.services.market_fetcher import _get_annual_financials_balance_cashflow, _get_row_series
|
||||
from server.services.financial_metrics import get_dupont_altman_redflags_yoy
|
||||
from server.services.financial_metrics_ext import get_income_statement_sankey_data
|
||||
from server.models.schemas import (
|
||||
DuPontTreeNode,
|
||||
DuPontTreePayload,
|
||||
@@ -31,6 +31,11 @@ try:
|
||||
except ImportError:
|
||||
yf = None # type: ignore[assignment]
|
||||
|
||||
_DASHBOARD_CACHE_TTL_SECONDS = 300
|
||||
_dashboard_cache_lock = threading.RLock()
|
||||
_dashboard_cache: Dict[str, tuple[float, ResearchDashboardResponse]] = {}
|
||||
_dashboard_inflight: Dict[str, threading.Event] = {}
|
||||
|
||||
_FSCORE_KEYS = [
|
||||
("profitable", "Net income > 0"),
|
||||
("ocf_pos", "Operating cash flow > 0"),
|
||||
@@ -197,15 +202,80 @@ def _dupont_tree_from_df(dupont_df: pd.DataFrame) -> Optional[DuPontTreePayload]
|
||||
)
|
||||
|
||||
|
||||
def _build_sankey_nivo(ticker: str) -> SankeyGraphPayload:
|
||||
d = get_income_statement_sankey_data(ticker)
|
||||
rev = max(d.get("revenue") or 0, 1)
|
||||
cogs = min(abs(d.get("cogs") or 0), rev * 0.999)
|
||||
gp = max(d.get("gross_profit") or 0, 0)
|
||||
opex = max(d.get("opex") or 0, 0)
|
||||
oi = d.get("operating_income") or 0
|
||||
tax = max(d.get("tax_interest_other") or 0, 0)
|
||||
ni = d.get("net_income") or 0
|
||||
def _build_dupont_tree_from_statements(fin: pd.DataFrame, bal: pd.DataFrame) -> Optional[DuPontTreePayload]:
|
||||
if fin is None or fin.empty or bal is None or bal.empty:
|
||||
return None
|
||||
|
||||
rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue")
|
||||
ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders")
|
||||
total_assets = _get_row_series(bal, "Total Assets")
|
||||
total_equity = _get_row_series(
|
||||
bal,
|
||||
"Total Stockholder Equity",
|
||||
"Stockholders Equity",
|
||||
"Total Equity Gross Minority Interest",
|
||||
)
|
||||
if rev is None or ni is None or total_assets is None or total_equity is None:
|
||||
return None
|
||||
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for i, d in enumerate(fin.columns[:6]):
|
||||
revenue = _safe_float(rev.get(d))
|
||||
net_income = _safe_float(ni.get(d))
|
||||
assets = _safe_float(total_assets.get(d))
|
||||
equity = _safe_float(total_equity.get(d))
|
||||
if not revenue or not assets or not equity:
|
||||
continue
|
||||
|
||||
npm = (net_income / revenue * 100) if net_income is not None else None
|
||||
at = revenue / assets if assets else None
|
||||
em = assets / equity if equity else None
|
||||
roe = (net_income / equity * 100) if (net_income is not None and equity) else None
|
||||
if npm is None or at is None or em is None or roe is None:
|
||||
continue
|
||||
|
||||
yr = int(str(d)[:4]) if str(d)[:4].isdigit() else (d.year if hasattr(d, "year") else (2024 - i))
|
||||
rows.append(
|
||||
{
|
||||
"Year": yr,
|
||||
"Revenue": revenue,
|
||||
"Net Income": net_income,
|
||||
"NPM %": round(npm, 2),
|
||||
"Asset Turnover": round(at, 4),
|
||||
"Equity Mult.": round(em, 2),
|
||||
"ROE %": round(roe, 2),
|
||||
}
|
||||
)
|
||||
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
return _dupont_tree_from_df(pd.DataFrame(rows))
|
||||
|
||||
|
||||
def _build_sankey_nivo_from_fin(fin: pd.DataFrame) -> SankeyGraphPayload:
|
||||
if fin is None or fin.empty:
|
||||
return SankeyGraphPayload(nodes=[], links=[])
|
||||
|
||||
rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue")
|
||||
cogs = _get_row_series(fin, "Cost Of Revenue", "Cost Of Goods Sold")
|
||||
gross = _get_row_series(fin, "Gross Profit")
|
||||
op_inc = _get_row_series(fin, "Operating Income", "EBIT")
|
||||
ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders")
|
||||
if rev is None or len(rev) == 0:
|
||||
return SankeyGraphPayload(nodes=[], links=[])
|
||||
|
||||
d = rev.index[0]
|
||||
revenue = abs(_safe_float(rev.get(d)) or 0)
|
||||
cogs_val = abs(_safe_float(cogs.get(d)) or 0) if cogs is not None and d in cogs.index else 0
|
||||
gross_val = _safe_float(gross.get(d)) if gross is not None and d in gross.index else None
|
||||
if gross_val is None:
|
||||
gross_val = (revenue - cogs_val) if revenue else 0
|
||||
gross_val = abs(gross_val or 0)
|
||||
operating_income = _safe_float(op_inc.get(d)) if op_inc is not None and d in op_inc.index else 0
|
||||
net_income = _safe_float(ni.get(d)) if ni is not None and d in ni.index else 0
|
||||
opex = max(0, gross_val - operating_income) if gross_val >= operating_income else 0
|
||||
tax_interest_other = max(0, operating_income - net_income) if (operating_income - net_income) > 0 else abs(min(0, operating_income - net_income))
|
||||
|
||||
nodes = [
|
||||
SankeyNivoNode(id="revenue", label="Revenue"),
|
||||
@@ -216,20 +286,27 @@ def _build_sankey_nivo(ticker: str) -> SankeyGraphPayload:
|
||||
SankeyNivoNode(id="tax_other", label="Tax & other"),
|
||||
SankeyNivoNode(id="net_income", label="Net income"),
|
||||
]
|
||||
revenue_safe = max(revenue, 1)
|
||||
gross_safe = max(gross_val, revenue_safe - min(cogs_val, revenue_safe * 0.999))
|
||||
links: List[SankeyNivoLink] = [
|
||||
SankeyNivoLink(source="revenue", target="cogs", value=float(cogs)),
|
||||
SankeyNivoLink(source="revenue", target="gross_profit", value=float(max(gp, rev - cogs))),
|
||||
SankeyNivoLink(source="revenue", target="cogs", value=float(min(cogs_val, revenue_safe * 0.999))),
|
||||
SankeyNivoLink(source="revenue", target="gross_profit", value=float(max(gross_safe, revenue_safe - cogs_val))),
|
||||
]
|
||||
gp_v = links[-1].value
|
||||
links.append(SankeyNivoLink(source="gross_profit", target="opex", value=float(min(opex, gp_v))))
|
||||
links.append(SankeyNivoLink(source="gross_profit", target="operating_income", value=float(max(oi, gp_v - opex))))
|
||||
links.append(SankeyNivoLink(source="gross_profit", target="operating_income", value=float(max(operating_income, gp_v - opex))))
|
||||
oi_v = links[-1].value
|
||||
tax_v = min(tax, max(oi_v, 0))
|
||||
tax_v = min(tax_interest_other, max(oi_v, 0))
|
||||
links.append(SankeyNivoLink(source="operating_income", target="tax_other", value=float(tax_v)))
|
||||
links.append(SankeyNivoLink(source="operating_income", target="net_income", value=float(max(abs(ni), 0))))
|
||||
links.append(SankeyNivoLink(source="operating_income", target="net_income", value=float(max(abs(net_income), 0))))
|
||||
return SankeyGraphPayload(nodes=nodes, links=links)
|
||||
|
||||
|
||||
def _build_sankey_nivo(ticker: str) -> SankeyGraphPayload:
|
||||
fin, _, _ = _get_annual_financials_balance_cashflow(ticker)
|
||||
return _build_sankey_nivo_from_fin(fin)
|
||||
|
||||
|
||||
def _build_waterfall(fin: pd.DataFrame) -> List[WaterfallStep]:
|
||||
if fin is None or fin.empty or len(fin.columns) < 2:
|
||||
return []
|
||||
@@ -352,8 +429,27 @@ def sankey_nivo_for_ticker(ticker: str) -> Dict[str, Any]:
|
||||
return _build_sankey_nivo(ticker).model_dump()
|
||||
|
||||
|
||||
def build_research_dashboard(ticker: str) -> ResearchDashboardResponse:
|
||||
sym = ticker.upper().strip()
|
||||
def _get_cached_dashboard(sym: str) -> Optional[ResearchDashboardResponse]:
|
||||
now = time.time()
|
||||
with _dashboard_cache_lock:
|
||||
cached = _dashboard_cache.get(sym)
|
||||
if not cached:
|
||||
return None
|
||||
ts, payload = cached
|
||||
if now - ts >= _DASHBOARD_CACHE_TTL_SECONDS:
|
||||
_dashboard_cache.pop(sym, None)
|
||||
return None
|
||||
return payload.model_copy(deep=True)
|
||||
|
||||
|
||||
def _store_cached_dashboard(sym: str, payload: ResearchDashboardResponse) -> None:
|
||||
if payload.error:
|
||||
return
|
||||
with _dashboard_cache_lock:
|
||||
_dashboard_cache[sym] = (time.time(), payload.model_copy(deep=True))
|
||||
|
||||
|
||||
def _build_research_dashboard_uncached(sym: str) -> ResearchDashboardResponse:
|
||||
fin, bal, cf = _get_annual_financials_balance_cashflow(sym)
|
||||
if fin is None or fin.empty or bal is None or bal.empty:
|
||||
return ResearchDashboardResponse(ticker=sym, error="Insufficient financial statements")
|
||||
@@ -362,11 +458,8 @@ def build_research_dashboard(ticker: str) -> ResearchDashboardResponse:
|
||||
cf = pd.DataFrame()
|
||||
|
||||
total, fseries = _build_fscore_series(sym, fin, bal, cf)
|
||||
dq = get_dupont_altman_redflags_yoy(sym)
|
||||
dupont_df = dq.get("dupont") if dq else None
|
||||
tree = _dupont_tree_from_df(dupont_df) if isinstance(dupont_df, pd.DataFrame) else None
|
||||
|
||||
sankey = _build_sankey_nivo(sym)
|
||||
tree = _build_dupont_tree_from_statements(fin, bal)
|
||||
sankey = _build_sankey_nivo_from_fin(fin)
|
||||
waterfall = _build_waterfall(fin)
|
||||
anomalies = _detect_anomalies(fin, bal)
|
||||
|
||||
@@ -379,3 +472,40 @@ def build_research_dashboard(ticker: str) -> ResearchDashboardResponse:
|
||||
waterfall=waterfall,
|
||||
anomalies=anomalies,
|
||||
)
|
||||
|
||||
|
||||
def build_research_dashboard(ticker: str) -> ResearchDashboardResponse:
|
||||
sym = ticker.upper().strip()
|
||||
cached = _get_cached_dashboard(sym)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
wait_event: Optional[threading.Event] = None
|
||||
with _dashboard_cache_lock:
|
||||
cached = _get_cached_dashboard(sym)
|
||||
if cached is not None:
|
||||
return cached
|
||||
wait_event = _dashboard_inflight.get(sym)
|
||||
if wait_event is None:
|
||||
wait_event = threading.Event()
|
||||
_dashboard_inflight[sym] = wait_event
|
||||
leader = True
|
||||
else:
|
||||
leader = False
|
||||
|
||||
if not leader:
|
||||
wait_event.wait(timeout=15)
|
||||
cached = _get_cached_dashboard(sym)
|
||||
if cached is not None:
|
||||
return cached
|
||||
return _build_research_dashboard_uncached(sym)
|
||||
|
||||
try:
|
||||
payload = _build_research_dashboard_uncached(sym)
|
||||
_store_cached_dashboard(sym, payload)
|
||||
return payload.model_copy(deep=True)
|
||||
finally:
|
||||
with _dashboard_cache_lock:
|
||||
event = _dashboard_inflight.pop(sym, None)
|
||||
if event is not None:
|
||||
event.set()
|
||||
|
||||
Reference in New Issue
Block a user