From 1527e7d75465390e551f0613f0ab7f3129c2a00e Mon Sep 17 00:00:00 2001 From: shawnkim1997 Date: Tue, 21 Apr 2026 20:23:48 +0100 Subject: [PATCH] phase 2: terminal state and gateway foundation --- atlas-terminal/.github/workflows/ci.yml | 49 +++ atlas-terminal/.gitignore | 26 ++ atlas-terminal/README.md | 29 +- atlas-terminal/apps/web/e2e/atlas-smoke.ts | 81 +++++ atlas-terminal/apps/web/e2e/macro.spec.ts | 13 + atlas-terminal/apps/web/e2e/news.spec.ts | 13 + atlas-terminal/apps/web/e2e/overview.spec.ts | 13 + atlas-terminal/apps/web/e2e/portfolio.spec.ts | 13 + atlas-terminal/apps/web/next.config.mjs | 29 +- atlas-terminal/apps/web/package.json | 12 +- atlas-terminal/apps/web/playwright.config.ts | 25 ++ .../apps/web/src/app/components/app-shell.tsx | 12 + .../web/src/app/components/chat-panel.tsx | 17 +- .../components/research/WaterfallWidget.tsx | 2 +- .../apps/web/src/app/components/sidebar.tsx | 99 ++++- .../web/src/app/components/ticker-bar.tsx | 29 +- atlas-terminal/apps/web/src/app/lib/flags.ts | 5 + .../apps/web/src/app/lib/ticker-alias.ts | 342 +++++++++++++++++- .../apps/web/src/app/lib/use-api.ts | 38 +- .../apps/web/src/app/lib/use-ticker.ts | 28 +- .../apps/web/src/app/portfolio/page.tsx | 20 +- .../apps/web/src/app/report/page.tsx | 57 ++- .../apps/web/src/app/research/page.tsx | 25 +- .../web/src/hooks/use-keyboard-shortcuts.ts | 72 ++++ .../apps/web/src/stores/terminal.ts | 165 +++++++++ .../apps/web/src/types/tinykeys.d.ts | 10 + atlas-terminal/apps/web/tsconfig.json | 22 +- atlas-terminal/docs/baseline-2026-04.md | 65 ++++ atlas-terminal/server/core/__init__.py | 29 ++ atlas-terminal/server/core/cache.py | 87 +++++ atlas-terminal/server/core/chained_gateway.py | 77 ++++ atlas-terminal/server/core/data_gateway.py | 134 +++++++ atlas-terminal/server/core/factory.py | 31 ++ atlas-terminal/server/core/flags.py | 15 + .../server/core/provider_metrics.py | 59 +++ .../server/core/providers/__init__.py | 19 + atlas-terminal/server/core/providers/base.py | 74 ++++ atlas-terminal/server/core/providers/fmp.py | 61 ++++ atlas-terminal/server/core/providers/kis.py | 28 ++ .../server/core/providers/yahooquery.py | 66 ++++ .../server/core/providers/yfinance.py | 103 ++++++ atlas-terminal/server/main.py | 3 +- atlas-terminal/server/models/schemas.py | 2 + atlas-terminal/server/routers/copilot.py | 66 ++++ atlas-terminal/server/routers/fx.py | 77 ++-- atlas-terminal/server/routers/market_data.py | 26 ++ atlas-terminal/server/routers/portfolio.py | 63 +++- atlas-terminal/server/services/dcf_engine.py | 4 +- .../server/services/exchange_resolver.py | 33 +- .../server/services/screenshot_ocr.py | 22 +- atlas-terminal/tests/test_data_gateway.py | 84 +++++ atlas-terminal/tests/test_smoke.py | 97 +++++ 52 files changed, 2408 insertions(+), 163 deletions(-) create mode 100644 atlas-terminal/.github/workflows/ci.yml create mode 100644 atlas-terminal/.gitignore create mode 100644 atlas-terminal/apps/web/e2e/atlas-smoke.ts create mode 100644 atlas-terminal/apps/web/e2e/macro.spec.ts create mode 100644 atlas-terminal/apps/web/e2e/news.spec.ts create mode 100644 atlas-terminal/apps/web/e2e/overview.spec.ts create mode 100644 atlas-terminal/apps/web/e2e/portfolio.spec.ts create mode 100644 atlas-terminal/apps/web/playwright.config.ts create mode 100644 atlas-terminal/apps/web/src/app/lib/flags.ts create mode 100644 atlas-terminal/apps/web/src/hooks/use-keyboard-shortcuts.ts create mode 100644 atlas-terminal/apps/web/src/stores/terminal.ts create mode 100644 atlas-terminal/apps/web/src/types/tinykeys.d.ts create mode 100644 atlas-terminal/docs/baseline-2026-04.md create mode 100644 atlas-terminal/server/core/__init__.py create mode 100644 atlas-terminal/server/core/cache.py create mode 100644 atlas-terminal/server/core/chained_gateway.py create mode 100644 atlas-terminal/server/core/data_gateway.py create mode 100644 atlas-terminal/server/core/factory.py create mode 100644 atlas-terminal/server/core/flags.py create mode 100644 atlas-terminal/server/core/provider_metrics.py create mode 100644 atlas-terminal/server/core/providers/__init__.py create mode 100644 atlas-terminal/server/core/providers/base.py create mode 100644 atlas-terminal/server/core/providers/fmp.py create mode 100644 atlas-terminal/server/core/providers/kis.py create mode 100644 atlas-terminal/server/core/providers/yahooquery.py create mode 100644 atlas-terminal/server/core/providers/yfinance.py create mode 100644 atlas-terminal/server/routers/copilot.py create mode 100644 atlas-terminal/tests/test_data_gateway.py create mode 100644 atlas-terminal/tests/test_smoke.py diff --git a/atlas-terminal/.github/workflows/ci.yml b/atlas-terminal/.github/workflows/ci.yml new file mode 100644 index 0000000..bfc1792 --- /dev/null +++ b/atlas-terminal/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install backend dependencies + run: pip install -r requirements.txt + + - name: Run pytest + run: pytest tests + + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/web + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install frontend dependencies + run: npm install + + - name: Typecheck + run: npm run typecheck + + - name: Build + run: npm run build + + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + + - name: Run e2e smoke tests + run: npm run e2e diff --git a/atlas-terminal/.gitignore b/atlas-terminal/.gitignore new file mode 100644 index 0000000..3cb20d4 --- /dev/null +++ b/atlas-terminal/.gitignore @@ -0,0 +1,26 @@ +# Python +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# Local env +.env +.env.local + +# Frontend +apps/web/node_modules/ +apps/web/.next/ +apps/web/.next-dev/ +apps/web/out/ +apps/web/test-results/ +apps/web/playwright-report/ + +# Local databases and personal data exports +server/data/*.db +server/data/*.db-* +data/ + +# macOS +.DS_Store diff --git a/atlas-terminal/README.md b/atlas-terminal/README.md index 890f958..65a659e 100644 --- a/atlas-terminal/README.md +++ b/atlas-terminal/README.md @@ -1,5 +1,7 @@ # ATLAS Terminal +[![CI](https://github.com/shawnkim1997/10-K-summariser-project/actions/workflows/ci.yml/badge.svg)](https://github.com/shawnkim1997/10-K-summariser-project/actions/workflows/ci.yml) + 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. @@ -49,7 +51,8 @@ You can also click the screenshot below to open the recorded walkthrough: ## Stack - Frontend: Next.js 14, React 18, TypeScript, Tailwind CSS, Recharts, Lightweight Charts -- Backend: FastAPI, Python 3.12+, Pydantic, yfinance, pandas, scipy +- Frontend state: Zustand persistent terminal store, shared API hook, Playwright smoke tests +- Backend: FastAPI, Python 3.12+, Pydantic, yfinance, yahooquery, FMP gateway scaffold, pandas, scipy - Data: SEC, DART, EDINET, FRED, OECD, DBnomics, Yahoo Finance - AI: Gemini for qualitative analysis only - Storage: SQLite by default @@ -82,6 +85,16 @@ npm install npm run dev ``` +### Verification + +```bash +pytest tests -q +cd apps/web +npm run typecheck +npm run build +npm run e2e +``` + ### Local URLs - Frontend: [http://localhost:3000](http://localhost:3000) @@ -90,11 +103,25 @@ npm run dev ## Recent Work +- v2 refactor foundation: baseline measurements in `docs/baseline-2026-04.md`, CI workflow, pytest smoke tests, and Playwright route smoke tests +- Data Gateway scaffold: typed `DataGateway` contract, chained providers, TTL cache wrapper, provider metrics, and a flag-gated `/api/market/quote/{ticker}` migration path via `ATLAS_FLAG_GATEWAY=true` +- Central terminal state: Zustand-backed `useTerminal` store for active symbol, page context, recent symbols, watchlist, currency, theme, layouts, and Copilot context +- Copilot context injection: right rail chat now sends terminal context to `/api/copilot/chat` on every turn +- Keyboard workflow: `Cmd/Ctrl+K` and `G` focus ticker search, `/` focuses Copilot, `W` adds the active symbol to watchlist, and `P/M/N` navigate Portfolio/Macro/News +- Smarter ticker search: company-name and Korean aliases now resolve suggestions such as Berkshire Hathaway, SK hynix, Samsung Electronics, Toyota, Novo Nordisk, and common ETFs/commodities +- Portfolio and FX reliability: exchange-aware Novo Nordisk EUR handling, faster FX/portfolio repeat loads, and cleaner local artifact ignore rules - 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 +## Refactor Roadmap + +- Phase 0: Foundation safety net, baseline docs, CI, backend smoke tests, frontend e2e smoke tests +- Phase 1: Data Gateway migration behind `ATLAS_FLAG_GATEWAY`, starting with low-risk quote data before wider overview/profile routes +- Phase 2: Global terminal state through Zustand, Copilot context, and keyboard-first terminal navigation +- Next phases: encrypted credential vault, peer comparison, earnings-call delta analysis, and smaller institutional feature gaps + ## Why This Project 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. diff --git a/atlas-terminal/apps/web/e2e/atlas-smoke.ts b/atlas-terminal/apps/web/e2e/atlas-smoke.ts new file mode 100644 index 0000000..aef4015 --- /dev/null +++ b/atlas-terminal/apps/web/e2e/atlas-smoke.ts @@ -0,0 +1,81 @@ +import { expect, type Page } from "@playwright/test"; + +export async function mockAtlasApi(page: Page) { + await page.route("**/api/**", async (route) => { + const url = new URL(route.request().url()); + const path = url.pathname; + + if (path.includes("/market/indices")) { + await route.fulfill({ + json: [ + { label: "S&P 500", symbol: "^GSPC", price: "5,000.00", change: "+0.10%", positive: true }, + { label: "NASDAQ", symbol: "^IXIC", price: "16,000.00", change: "+0.20%", positive: true }, + ], + }); + return; + } + + if (path.includes("/market/overview/")) { + await route.fulfill({ json: { asset_type: "equity", data: { name: "NVIDIA Corporation" } } }); + return; + } + + if (path.includes("/market/sector/")) { + await route.fulfill({ json: { sector: "Technology", industry: "Semiconductors" } }); + return; + } + + if (path.includes("/market/health/")) { + await route.fulfill({ json: { altman_z: 8.4, piotroski_score: 7 } }); + return; + } + + if (path.includes("/portfolio/summary")) { + await route.fulfill({ json: { positions: [], total_value: 0, total_pnl: 0, total_pnl_pct: 0 } }); + return; + } + + if (path.includes("/fx/rates")) { + await route.fulfill({ json: { rates: { USD_USD: 1, EUR_USD: 1.08, DKK_USD: 0.15 } } }); + return; + } + + if (path.includes("/news/")) { + await route.fulfill({ json: [] }); + return; + } + + if (path.includes("/macro/fred/")) { + await route.fulfill({ json: { data: [{ date: "2026-01-01", value: 4.0 }] } }); + return; + } + + await route.fulfill({ json: {} }); + }); +} + +export async function seedTicker(page: Page, ticker = "NVDA") { + await page.addInitScript((value) => { + window.localStorage.setItem("atlas_active_ticker", value); + window.localStorage.setItem( + "atlas-terminal", + JSON.stringify({ + state: { + activeSymbol: value, + activePage: "equity", + recentSymbols: [value], + currency: "USD", + theme: "bloomberg", + watchlist: [], + layouts: {}, + }, + version: 1, + }), + ); + }, ticker); +} + +export async function expectAtlasShell(page: Page) { + await expect(page.getByText("ATLAS Desk")).toBeVisible(); + await expect(page.getByText("AI Copilot")).toBeVisible(); +} diff --git a/atlas-terminal/apps/web/e2e/macro.spec.ts b/atlas-terminal/apps/web/e2e/macro.spec.ts new file mode 100644 index 0000000..687d1e6 --- /dev/null +++ b/atlas-terminal/apps/web/e2e/macro.spec.ts @@ -0,0 +1,13 @@ +import { expect, test } from "@playwright/test"; + +import { expectAtlasShell, mockAtlasApi, seedTicker } from "./atlas-smoke"; + +test("macro dashboard route loads", async ({ page }) => { + await mockAtlasApi(page); + await seedTicker(page, "NVDA"); + + await page.goto("/macro"); + + await expectAtlasShell(page); + await expect(page.getByRole("heading", { name: "Global Macro & Smart Money" })).toBeVisible(); +}); diff --git a/atlas-terminal/apps/web/e2e/news.spec.ts b/atlas-terminal/apps/web/e2e/news.spec.ts new file mode 100644 index 0000000..2401ba2 --- /dev/null +++ b/atlas-terminal/apps/web/e2e/news.spec.ts @@ -0,0 +1,13 @@ +import { expect, test } from "@playwright/test"; + +import { expectAtlasShell, mockAtlasApi, seedTicker } from "./atlas-smoke"; + +test("news route loads with mocked articles", async ({ page }) => { + await mockAtlasApi(page); + await seedTicker(page, "NVDA"); + + await page.goto("/news"); + + await expectAtlasShell(page); + await expect(page.getByRole("heading", { name: "NVDA News Feed" })).toBeVisible(); +}); diff --git a/atlas-terminal/apps/web/e2e/overview.spec.ts b/atlas-terminal/apps/web/e2e/overview.spec.ts new file mode 100644 index 0000000..d67ba19 --- /dev/null +++ b/atlas-terminal/apps/web/e2e/overview.spec.ts @@ -0,0 +1,13 @@ +import { expect, test } from "@playwright/test"; + +import { expectAtlasShell, mockAtlasApi, seedTicker } from "./atlas-smoke"; + +test("overview loads with the ATLAS shell", async ({ page }) => { + await mockAtlasApi(page); + await seedTicker(page, "NVDA"); + + await page.goto("/"); + + await expectAtlasShell(page); + await expect(page.getByRole("heading", { name: "NVDA Overview" })).toBeVisible(); +}); diff --git a/atlas-terminal/apps/web/e2e/portfolio.spec.ts b/atlas-terminal/apps/web/e2e/portfolio.spec.ts new file mode 100644 index 0000000..4db6d69 --- /dev/null +++ b/atlas-terminal/apps/web/e2e/portfolio.spec.ts @@ -0,0 +1,13 @@ +import { expect, test } from "@playwright/test"; + +import { expectAtlasShell, mockAtlasApi, seedTicker } from "./atlas-smoke"; + +test("portfolio loads without live provider calls", async ({ page }) => { + await mockAtlasApi(page); + await seedTicker(page, "NVDA"); + + await page.goto("/portfolio"); + + await expectAtlasShell(page); + await expect(page.getByRole("heading", { name: "Portfolio" })).toBeVisible(); +}); diff --git a/atlas-terminal/apps/web/next.config.mjs b/atlas-terminal/apps/web/next.config.mjs index d0744c8..170ead8 100644 --- a/atlas-terminal/apps/web/next.config.mjs +++ b/atlas-terminal/apps/web/next.config.mjs @@ -1,13 +1,18 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - async rewrites() { - return [ - { - source: "/api/:path*", - destination: "http://localhost:8000/api/:path*", - }, - ]; - }, -}; +import { PHASE_DEVELOPMENT_SERVER } from "next/constants.js"; -export default nextConfig; +/** @type {(phase: string) => import('next').NextConfig} */ +export default function nextConfig(phase) { + return { + // Keep dev and production build artifacts separate so `next build` + // does not invalidate a running `next dev` session. + distDir: phase === PHASE_DEVELOPMENT_SERVER ? ".next-dev" : ".next", + async rewrites() { + return [ + { + source: "/api/:path*", + destination: "http://localhost:8000/api/:path*", + }, + ]; + }, + }; +} diff --git a/atlas-terminal/apps/web/package.json b/atlas-terminal/apps/web/package.json index 1d7e09b..c71c040 100644 --- a/atlas-terminal/apps/web/package.json +++ b/atlas-terminal/apps/web/package.json @@ -7,9 +7,12 @@ "build": "next build", "start": "next start", "lint": "next lint", - "clean": "rm -rf .next node_modules/.cache", + "typecheck": "tsc --noEmit --incremental false", + "e2e": "playwright test", + "e2e:install": "playwright install chromium", + "clean": "rm -rf .next .next-dev node_modules/.cache", "dev:clean": "npm run clean && next dev -p 3000", - "dev:reset": "rm -rf .next node_modules/.cache && next dev -p 3000", + "dev:reset": "rm -rf .next .next-dev node_modules/.cache && next dev -p 3000", "build:clean": "npm run clean && next build" }, "dependencies": { @@ -21,9 +24,12 @@ "next": "14.2.35", "react": "^18", "react-dom": "^18", - "recharts": "^2.15.4" + "recharts": "^2.15.4", + "tinykeys": "^3.0.0", + "zustand": "^5.0.12" }, "devDependencies": { + "@playwright/test": "^1.59.1", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/atlas-terminal/apps/web/playwright.config.ts b/atlas-terminal/apps/web/playwright.config.ts new file mode 100644 index 0000000..b838739 --- /dev/null +++ b/atlas-terminal/apps/web/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + timeout: 30_000, + expect: { + timeout: 5_000, + }, + use: { + baseURL: "http://127.0.0.1:3000", + trace: "on-first-retry", + }, + webServer: { + command: "npm run dev", + url: "http://127.0.0.1:3000", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/atlas-terminal/apps/web/src/app/components/app-shell.tsx b/atlas-terminal/apps/web/src/app/components/app-shell.tsx index 4ecd1e0..92a801d 100644 --- a/atlas-terminal/apps/web/src/app/components/app-shell.tsx +++ b/atlas-terminal/apps/web/src/app/components/app-shell.tsx @@ -1,8 +1,12 @@ "use client"; import dynamic from "next/dynamic"; +import { useEffect } from "react"; +import { usePathname } from "next/navigation"; import { TickerBar } from "./ticker-bar"; import { ChatPanel } from "./chat-panel"; +import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; +import { terminalPageFromPathname, useTerminal } from "@/stores/terminal"; /** * Sidebar uses usePathname(). In Next App Router, if SSR output and client first paint @@ -24,6 +28,14 @@ const SidebarClient = dynamic( ); export function AppShell({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const setActivePage = useTerminal((state) => state.setActivePage); + useKeyboardShortcuts(); + + useEffect(() => { + setActivePage(terminalPageFromPathname(pathname)); + }, [pathname, setActivePage]); + return ( <> diff --git a/atlas-terminal/apps/web/src/app/components/chat-panel.tsx b/atlas-terminal/apps/web/src/app/components/chat-panel.tsx index a76cd79..fd09a82 100644 --- a/atlas-terminal/apps/web/src/app/components/chat-panel.tsx +++ b/atlas-terminal/apps/web/src/app/components/chat-panel.tsx @@ -1,14 +1,15 @@ "use client"; import { useState, useEffect, useRef } from "react"; import { Bot, SendHorizontal } from "lucide-react"; -import { useTicker } from "../lib/use-ticker"; +import { useTerminal } from "@/stores/terminal"; export function ChatPanel() { const [messages, setMessages] = useState>([]); const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); const scrollRef = useRef(null); - const { ticker } = useTicker(); + const ticker = useTerminal((state) => state.activeSymbol || "AAPL"); + const buildCopilotContext = useTerminal((state) => state.buildCopilotContext); useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); @@ -23,14 +24,19 @@ export function ChatPanel() { try { const apiKey = localStorage.getItem("atlas_gemini_key") || ""; - const res = await fetch("/api/analysis/strategy", { + const res = await fetch("/api/copilot/chat", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ticker, question: userMsg, api_key: apiKey }), + body: JSON.stringify({ + message: userMsg, + context: buildCopilotContext(), + history: messages, + api_key: apiKey, + }), }); if (res.ok) { const data = await res.json(); - const text = typeof data === "string" ? data : data.analysis || data.result || JSON.stringify(data); + const text = typeof data === "string" ? data : data.message || data.analysis || data.result || JSON.stringify(data); setMessages((prev) => [...prev, { role: "assistant", content: text }]); } else { setMessages((prev) => [ @@ -103,6 +109,7 @@ export function ChatPanel() {
setInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleSend()} diff --git a/atlas-terminal/apps/web/src/app/components/research/WaterfallWidget.tsx b/atlas-terminal/apps/web/src/app/components/research/WaterfallWidget.tsx index 2b8685f..406e211 100644 --- a/atlas-terminal/apps/web/src/app/components/research/WaterfallWidget.tsx +++ b/atlas-terminal/apps/web/src/app/components/research/WaterfallWidget.tsx @@ -131,7 +131,7 @@ export function WaterfallWidget({ steps }: { steps: WaterfallStep[] }) { }} labelSkipWidth={12} labelSkipHeight={12} - labelTextColor={chartPalette.text} + labelTextColor={chartPalette.canvas} theme={barTheme} tooltip={({ value, indexValue }) => (
diff --git a/atlas-terminal/apps/web/src/app/components/sidebar.tsx b/atlas-terminal/apps/web/src/app/components/sidebar.tsx index eaa8a14..8879afb 100644 --- a/atlas-terminal/apps/web/src/app/components/sidebar.tsx +++ b/atlas-terminal/apps/web/src/app/components/sidebar.tsx @@ -2,7 +2,8 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { BarChart3, Briefcase, CalendarRange, FileSearch, FileText, Globe, Landmark, LineChart, Microscope, Newspaper, Search, Settings, Sparkles, Target, TrendingUp } from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; +import { searchTickerSuggestions, type TickerSuggestion } from "../lib/ticker-alias"; import { useTicker } from "../lib/use-ticker"; const NAV_ITEMS = [ @@ -23,13 +24,54 @@ const NAV_ITEMS = [ export function Sidebar() { const pathname = usePathname(); const [input, setInput] = useState(""); + const [highlightedIndex, setHighlightedIndex] = useState(0); const { ticker, setTicker } = useTicker(); + const suggestions = useMemo(() => searchTickerSuggestions(input, 5), [input]); + const showSuggestions = input.trim().length > 0 && suggestions.length > 0; + + function selectSuggestion(suggestion: TickerSuggestion) { + setTicker(suggestion.ticker); + setInput(""); + setHighlightedIndex(0); + } function handleSearch() { const val = input.trim(); if (val) { + const bestMatch = suggestions[highlightedIndex] ?? suggestions[0]; + if (bestMatch) { + selectSuggestion(bestMatch); + return; + } setTicker(val); setInput(""); + setHighlightedIndex(0); + } + } + + function handleInputChange(value: string) { + setInput(value); + setHighlightedIndex(0); + } + + function handleKeyDown(event: React.KeyboardEvent) { + if (event.key === "Enter") { + event.preventDefault(); + handleSearch(); + return; + } + + if (!showSuggestions) return; + + if (event.key === "ArrowDown") { + event.preventDefault(); + setHighlightedIndex((current) => (current + 1) % suggestions.length); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setHighlightedIndex((current) => (current - 1 + suggestions.length) % suggestions.length); + } else if (event.key === "Escape") { + setInput(""); + setHighlightedIndex(0); } } @@ -45,16 +87,63 @@ export function Sidebar() {
-
+
setInput(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSearch()} - placeholder="Search ticker..." + onChange={(e) => handleInputChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search ticker or company..." + aria-label="Search ticker or company" + aria-autocomplete="list" className="w-full border-none bg-transparent text-sm text-text-primary outline-none" />
+ {showSuggestions && ( +
+ {suggestions.map((suggestion, index) => { + const active = index === highlightedIndex; + return ( + + ); + })} +
+ )} + {input.trim().length > 0 && suggestions.length === 0 && ( +
+ No match yet. Press Enter to use {input.trim().toUpperCase()}. +
+ )}
Ticker {ticker} diff --git a/atlas-terminal/apps/web/src/app/components/ticker-bar.tsx b/atlas-terminal/apps/web/src/app/components/ticker-bar.tsx index bb2ec41..c07b886 100644 --- a/atlas-terminal/apps/web/src/app/components/ticker-bar.tsx +++ b/atlas-terminal/apps/web/src/app/components/ticker-bar.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { Dot } from "lucide-react"; +import { useApi } from "../lib/use-api"; interface IndexData { label: string; @@ -17,29 +18,17 @@ const INDICES = [ { label: "BTC", symbol: "BTC-USD" }, ]; +const DEFAULT_INDICES: IndexData[] = INDICES.map((i) => ({ ...i, price: "—", change: "—", positive: true })); + export function TickerBar() { - const [data, setData] = useState( - INDICES.map((i) => ({ ...i, price: "—", change: "—", positive: true })) - ); + const indices = useApi("/api/market/indices", { cacheTtlMs: 60_000 }); useEffect(() => { - async function load() { - try { - const res = await fetch(`/api/market/indices`); - if (res.ok) { - const json = await res.json(); - if (Array.isArray(json)) { - setData(json); - } - } - } catch { - // keep defaults - } - } - load(); - const iv = setInterval(load, 60_000); + const iv = setInterval(indices.refetch, 60_000); return () => clearInterval(iv); - }, []); + }, [indices.refetch]); + + const data = Array.isArray(indices.data) ? indices.data : DEFAULT_INDICES; return (
diff --git a/atlas-terminal/apps/web/src/app/lib/flags.ts b/atlas-terminal/apps/web/src/app/lib/flags.ts new file mode 100644 index 0000000..38a6b95 --- /dev/null +++ b/atlas-terminal/apps/web/src/app/lib/flags.ts @@ -0,0 +1,5 @@ +export const flags = { + newDataGateway: process.env.NEXT_PUBLIC_FLAG_GATEWAY === "true", + peerCompare: process.env.NEXT_PUBLIC_FLAG_PEER === "true", + earningsDelta: process.env.NEXT_PUBLIC_FLAG_EARNINGS === "true", +} as const; diff --git a/atlas-terminal/apps/web/src/app/lib/ticker-alias.ts b/atlas-terminal/apps/web/src/app/lib/ticker-alias.ts index b94757f..b4cd528 100644 --- a/atlas-terminal/apps/web/src/app/lib/ticker-alias.ts +++ b/atlas-terminal/apps/web/src/app/lib/ticker-alias.ts @@ -1,15 +1,238 @@ -const TICKER_ALIASES: Record = { - // Equities (natural language -> Yahoo suffix) - samsung: "005930.KS", +export type TickerSuggestion = { + ticker: string; + name: string; + exchange: string; + assetType: "Equity" | "ETF" | "Commodity" | "Crypto" | "Index"; + country?: string; + aliases: string[]; +}; - // Commodities (natural language -> futures ticker) - gold: "GC=F", - silver: "SI=F", +export const TICKER_DIRECTORY: TickerSuggestion[] = [ + { + ticker: "BRK-B", + name: "Berkshire Hathaway Inc. Class B", + exchange: "NYSE", + assetType: "Equity", + country: "US", + aliases: ["berkshire hathaway", "berkshire", "brk.b", "brkb", "buffett", "warren buffett", "버크셔해서웨이", "버크셔 해서웨이", "버크셔"], + }, + { + ticker: "BRK-A", + name: "Berkshire Hathaway Inc. Class A", + exchange: "NYSE", + assetType: "Equity", + country: "US", + aliases: ["berkshire hathaway class a", "berkshire class a", "brk.a", "brka"], + }, + { + ticker: "000660.KS", + name: "SK hynix Inc.", + exchange: "KOSPI", + assetType: "Equity", + country: "KR", + aliases: ["sk hynix", "skhynix", "hynix", "sk하이닉스", "sk 하이닉스", "에스케이하이닉스", "에스케이 하이닉스", "하이닉스"], + }, + { + ticker: "005930.KS", + name: "Samsung Electronics Co., Ltd.", + exchange: "KOSPI", + assetType: "Equity", + country: "KR", + aliases: ["samsung", "samsung electronics", "삼성전자", "삼성 전자", "삼전"], + }, + { + ticker: "005380.KS", + name: "Hyundai Motor Company", + exchange: "KOSPI", + assetType: "Equity", + country: "KR", + aliases: ["hyundai motor", "hyundai motors", "현대차", "현대자동차", "현대 자동차"], + }, + { + ticker: "035420.KS", + name: "NAVER Corporation", + exchange: "KOSPI", + assetType: "Equity", + country: "KR", + aliases: ["naver", "네이버"], + }, + { + ticker: "035720.KS", + name: "Kakao Corp.", + exchange: "KOSPI", + assetType: "Equity", + country: "KR", + aliases: ["kakao", "카카오"], + }, + { + ticker: "7203.T", + name: "Toyota Motor Corporation", + exchange: "Tokyo", + assetType: "Equity", + country: "JP", + aliases: ["toyota", "toyota motor", "토요타", "도요타"], + }, + { + ticker: "6758.T", + name: "Sony Group Corporation", + exchange: "Tokyo", + assetType: "Equity", + country: "JP", + aliases: ["sony", "sony group", "소니"], + }, + { + ticker: "NOV.F", + name: "Novo Nordisk A/S", + exchange: "Frankfurt", + assetType: "Equity", + country: "DK", + aliases: ["novo nordisk eur", "novo nordisk euro", "novo", "novonordisk", "노보노디스크", "노보 노디스크"], + }, + { + ticker: "NVO", + name: "Novo Nordisk A/S ADR", + exchange: "NYSE", + assetType: "Equity", + country: "DK", + aliases: ["novo nordisk adr", "nvo adr"], + }, + { + ticker: "AAPL", + name: "Apple Inc.", + exchange: "NASDAQ", + assetType: "Equity", + country: "US", + aliases: ["apple", "애플"], + }, + { + ticker: "MSFT", + name: "Microsoft Corporation", + exchange: "NASDAQ", + assetType: "Equity", + country: "US", + aliases: ["microsoft", "마이크로소프트"], + }, + { + ticker: "NVDA", + name: "NVIDIA Corporation", + exchange: "NASDAQ", + assetType: "Equity", + country: "US", + aliases: ["nvidia", "엔비디아"], + }, + { + ticker: "TSLA", + name: "Tesla, Inc.", + exchange: "NASDAQ", + assetType: "Equity", + country: "US", + aliases: ["tesla", "테슬라"], + }, + { + ticker: "GOOGL", + name: "Alphabet Inc. Class A", + exchange: "NASDAQ", + assetType: "Equity", + country: "US", + aliases: ["google", "alphabet", "구글", "알파벳"], + }, + { + ticker: "META", + name: "Meta Platforms, Inc.", + exchange: "NASDAQ", + assetType: "Equity", + country: "US", + aliases: ["meta", "facebook", "메타", "페이스북"], + }, + { + ticker: "AMZN", + name: "Amazon.com, Inc.", + exchange: "NASDAQ", + assetType: "Equity", + country: "US", + aliases: ["amazon", "아마존"], + }, + { + ticker: "PLTR", + name: "Palantir Technologies Inc.", + exchange: "NASDAQ", + assetType: "Equity", + country: "US", + aliases: ["palantir", "팔란티어"], + }, + { + ticker: "IREN", + name: "IREN Limited", + exchange: "NASDAQ", + assetType: "Equity", + country: "AU", + aliases: ["iren", "iris energy"], + }, + { + ticker: "SPY", + name: "SPDR S&P 500 ETF Trust", + exchange: "NYSE Arca", + assetType: "ETF", + country: "US", + aliases: ["s&p 500 etf", "sp500 etf", "snp 500 etf"], + }, + { + ticker: "QQQ", + name: "Invesco QQQ Trust", + exchange: "NASDAQ", + assetType: "ETF", + country: "US", + aliases: ["nasdaq 100 etf", "nasdaq etf", "나스닥 etf"], + }, + { + ticker: "GC=F", + name: "Gold Futures", + exchange: "COMEX", + assetType: "Commodity", + aliases: ["gold", "금"], + }, + { + ticker: "SI=F", + name: "Silver Futures", + exchange: "COMEX", + assetType: "Commodity", + aliases: ["silver", "은"], + }, + { + ticker: "CL=F", + name: "WTI Crude Oil Futures", + exchange: "NYMEX", + assetType: "Commodity", + aliases: ["oil", "crude", "crude oil", "wti", "원유"], + }, + { + ticker: "BTC-USD", + name: "Bitcoin USD", + exchange: "Crypto", + assetType: "Crypto", + aliases: ["bitcoin", "btc", "비트코인"], + }, + { + ticker: "^GSPC", + name: "S&P 500 Index", + exchange: "Index", + assetType: "Index", + country: "US", + aliases: ["s&p 500", "sp500", "snp500"], + }, + { + ticker: "^IXIC", + name: "NASDAQ Composite", + exchange: "Index", + assetType: "Index", + country: "US", + aliases: ["nasdaq", "nasdaq composite", "나스닥"], + }, +]; + +const LEGACY_TICKER_ALIASES: Record = { platinum: "PL=F", palladium: "PA=F", - oil: "CL=F", - crude: "CL=F", - "crude oil": "CL=F", brent: "BZ=F", gas: "NG=F", "natural gas": "NG=F", @@ -19,8 +242,6 @@ const TICKER_ALIASES: Record = { soybeans: "ZS=F", coffee: "KC=F", sugar: "SB=F", - - // Popular commodity ETFs gld: "GLD", slv: "SLV", uso: "USO", @@ -29,11 +250,104 @@ const TICKER_ALIASES: Record = { gsg: "GSG", }; +function normalizeSearchText(value: string): string { + return value + .trim() + .normalize("NFKC") + .toLowerCase() + .replace(/[._/\\|()[\]{}'",:;]+/g, " ") + .replace(/&/g, " and ") + .replace(/-/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function compactSearchText(value: string): string { + return normalizeSearchText(value).replace(/\s+/g, ""); +} + +function suggestionTerms(suggestion: TickerSuggestion): string[] { + return [suggestion.ticker, suggestion.name, suggestion.exchange, suggestion.country ?? "", suggestion.assetType, ...suggestion.aliases].filter(Boolean); +} + +function initials(value: string): string { + return normalizeSearchText(value) + .split(" ") + .filter(Boolean) + .map((part) => part[0]) + .join(""); +} + +function scoreSuggestion(suggestion: TickerSuggestion, query: string, compactQuery: string): number { + if (!query) return 0; + + const terms = suggestionTerms(suggestion); + let best = 0; + + for (const term of terms) { + const normalized = normalizeSearchText(term); + const compact = compactSearchText(term); + + if (normalized === query) best = Math.max(best, term === suggestion.ticker ? 1200 : 1000); + if (compact === compactQuery) best = Math.max(best, term === suggestion.ticker ? 1150 : 950); + if (normalized.startsWith(query)) best = Math.max(best, term === suggestion.ticker ? 900 : 760); + if (compact.startsWith(compactQuery)) best = Math.max(best, term === suggestion.ticker ? 850 : 720); + if (normalized.includes(query)) best = Math.max(best, 520); + if (compact.includes(compactQuery)) best = Math.max(best, 480); + if (compactQuery.length >= 2 && initials(term) === compactQuery) best = Math.max(best, 420); + } + + return best; +} + +export function searchTickerSuggestions(raw: string, limit = 6): TickerSuggestion[] { + const query = normalizeSearchText(raw); + const compactQuery = compactSearchText(raw); + if (!query) return []; + + return TICKER_DIRECTORY.map((suggestion) => ({ + suggestion, + score: scoreSuggestion(suggestion, query, compactQuery), + })) + .filter((item) => item.score > 0) + .sort((a, b) => b.score - a.score || a.suggestion.ticker.localeCompare(b.suggestion.ticker)) + .slice(0, limit) + .map((item) => item.suggestion); +} + +export function resolveTickerFromSearch(raw: string): string | null { + const query = normalizeSearchText(raw); + const compactQuery = compactSearchText(raw); + if (!query) return null; + + for (const suggestion of TICKER_DIRECTORY) { + const terms = suggestionTerms(suggestion); + if ( + terms.some((term) => { + const normalized = normalizeSearchText(term); + const compact = compactSearchText(term); + return normalized === query || compact === compactQuery; + }) + ) { + return suggestion.ticker; + } + } + + if (query.length >= 3) { + const [top] = searchTickerSuggestions(raw, 1); + if (!top) return null; + const terms = suggestionTerms(top); + const confidentPrefix = terms.some((term) => normalizeSearchText(term).startsWith(query) || compactSearchText(term).startsWith(compactQuery)); + if (confidentPrefix) return top.ticker; + } + + return null; +} + export function normalizeTickerInput(raw: string): string { const cleaned = raw.trim(); if (!cleaned) return ""; - const key = cleaned.toLowerCase(); - const mapped = TICKER_ALIASES[key] || cleaned; + const key = normalizeSearchText(cleaned); + const mapped = resolveTickerFromSearch(cleaned) || LEGACY_TICKER_ALIASES[key] || cleaned; return mapped.toUpperCase(); } - diff --git a/atlas-terminal/apps/web/src/app/lib/use-api.ts b/atlas-terminal/apps/web/src/app/lib/use-api.ts index 5e79688..0f760a2 100644 --- a/atlas-terminal/apps/web/src/app/lib/use-api.ts +++ b/atlas-terminal/apps/web/src/app/lib/use-api.ts @@ -9,12 +9,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; * - 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. + * - Short completed-response cache for GET route revisits. * - 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; @@ -25,7 +23,13 @@ interface InFlightEntry { refCount: number; } +interface CacheEntry { + payload: unknown; + expiresAt: number; +} + const inflight = new Map(); +const responseCache = new Map(); function makeKey(url: string, method: string, body?: string): string { return `${method.toUpperCase()} ${url}${body ? ` :: ${body}` : ""}`; @@ -101,6 +105,7 @@ export interface UseApiOptions { method?: "GET" | "POST"; body?: Json; headers?: Record; + cacheTtlMs?: number; } export interface UseApiResult { @@ -114,7 +119,7 @@ export function useApi( url: string | null, opts: UseApiOptions = {}, ): UseApiResult { - const { enabled = true, method = "GET", body, headers } = opts; + const { enabled = true, method = "GET", body, headers, cacheTtlMs } = opts; const [data, setData] = useState(null); const [loading, setLoading] = useState(Boolean(url) && enabled !== false); @@ -130,6 +135,7 @@ export function useApi( }, []); const bodyStr = body !== undefined ? JSON.stringify(body) : undefined; + const ttlMs = cacheTtlMs ?? (method === "GET" ? 60_000 : 0); useEffect(() => { if (!url || enabled === false) { @@ -138,9 +144,6 @@ export function useApi( } const controller = new AbortController(); - setLoading(true); - setError(null); - const init: RequestInit = { method, headers: { @@ -149,6 +152,19 @@ export function useApi( }, body: bodyStr, }; + const cacheKey = makeKey(url, method, bodyStr); + const cached = ttlMs > 0 ? responseCache.get(cacheKey) : undefined; + if (cached && cached.expiresAt > Date.now()) { + setData(cached.payload as T); + setError(null); + setLoading(false); + return () => { + controller.abort(); + }; + } + + setLoading(true); + setError(null); sharedFetch(url, init, controller.signal) .then(async (resp) => { @@ -159,6 +175,9 @@ export function useApi( 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; + if (ttlMs > 0) { + responseCache.set(cacheKey, { payload, expiresAt: Date.now() + ttlMs }); + } setData(payload as T); setError(null); }) @@ -177,11 +196,12 @@ export function useApi( controller.abort(); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [url, method, bodyStr, enabled, tick]); + }, [url, method, bodyStr, enabled, tick, ttlMs]); const refetch = useCallback(() => { + if (url) responseCache.delete(makeKey(url, method, bodyStr)); setTick((t) => t + 1); - }, []); + }, [bodyStr, method, url]); return { data, loading, error, refetch }; } diff --git a/atlas-terminal/apps/web/src/app/lib/use-ticker.ts b/atlas-terminal/apps/web/src/app/lib/use-ticker.ts index be0ff90..b7b9184 100644 --- a/atlas-terminal/apps/web/src/app/lib/use-ticker.ts +++ b/atlas-terminal/apps/web/src/app/lib/use-ticker.ts @@ -1,5 +1,6 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useEffect, useCallback } from "react"; +import { useTerminal } from "@/stores/terminal"; import { normalizeTickerInput } from "./ticker-alias"; const DEFAULT_TICKER = "AAPL"; @@ -7,33 +8,36 @@ const STORAGE_KEY = "atlas_active_ticker"; const EVENT_NAME = "atlas-ticker-change"; export function useTicker() { - // Must match server render: never read localStorage in useState initializer — hydration mismatch → white screen. - const [ticker, setTickerState] = useState(DEFAULT_TICKER); - const [initialized, setInitialized] = useState(false); + const ticker = useTerminal((state) => state.activeSymbol || DEFAULT_TICKER); + const initialized = useTerminal((state) => state.hydrated); + const setActiveSymbol = useTerminal((state) => state.setActiveSymbol); useEffect(() => { + if (!initialized) return; + + // One-time migration from the old ticker key. The Zustand store is the + // source of truth after Phase 2, but this preserves existing local setups. const saved = localStorage.getItem(STORAGE_KEY); - if (saved) { - const n = normalizeTickerInput(saved); - if (n) setTickerState(n); + if (saved && normalizeTickerInput(ticker) === DEFAULT_TICKER) { + const normalized = normalizeTickerInput(saved); + if (normalized && normalized !== DEFAULT_TICKER) setActiveSymbol(normalized); } - setInitialized(true); const handler = (e: Event) => { const detail = (e as CustomEvent).detail; - if (detail) setTickerState(detail); + if (detail) setActiveSymbol(detail); }; window.addEventListener(EVENT_NAME, handler); return () => window.removeEventListener(EVENT_NAME, handler); - }, []); + }, [initialized, setActiveSymbol, ticker]); const setTicker = useCallback((val: string) => { const upper = normalizeTickerInput(val); if (!upper) return; - setTickerState(upper); + setActiveSymbol(upper); localStorage.setItem(STORAGE_KEY, upper); window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: upper })); - }, []); + }, [setActiveSymbol]); return { ticker, setTicker, initialized }; } diff --git a/atlas-terminal/apps/web/src/app/portfolio/page.tsx b/atlas-terminal/apps/web/src/app/portfolio/page.tsx index ad3c476..251924a 100644 --- a/atlas-terminal/apps/web/src/app/portfolio/page.tsx +++ b/atlas-terminal/apps/web/src/app/portfolio/page.tsx @@ -25,6 +25,13 @@ interface Position { exchange?: string; } +interface ExchangeOption { + exchange: string; + yf_ticker: string; + currency: string; + default?: boolean; +} + export default function PortfolioPage() { const [positions, setPositions] = useState([]); const [form, setForm] = useState({ ticker: "", quantity: "", avg_price: "" }); @@ -41,7 +48,7 @@ export default function PortfolioPage() { const [displayCurrency, setDisplayCurrency] = useState("USD"); const [fxRates, setFxRates] = useState>({}); const [exchangeSelections, setExchangeSelections] = useState>({}); - const [exchangeOptions, setExchangeOptions] = useState>({}); + const [exchangeOptions, setExchangeOptions] = useState>({}); useEffect(() => { fetchPortfolio(); @@ -63,7 +70,10 @@ export default function PortfolioPage() { const opts = Array.isArray(data?.options) ? data.options : []; if (opts.length > 0) { setExchangeOptions((prev) => ({ ...prev, [rowKey]: opts })); - const def = opts.find((o: { default?: boolean; exchange: string }) => o.default) || opts[0]; + const def = + opts.find((o: ExchangeOption) => o.exchange === pos.exchange) || + opts.find((o: ExchangeOption) => o.default) || + opts[0]; setExchangeSelections((prev) => ({ ...prev, [rowKey]: def.exchange })); } }); @@ -435,7 +445,7 @@ export default function PortfolioPage() { {hasMultiple ? (