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 +[](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 ( + { + event.preventDefault(); + selectSuggestion(suggestion); + }} + onMouseEnter={() => setHighlightedIndex(index)} + className={`w-full px-3 py-2 text-left transition-colors ${ + active ? "bg-brand-navy text-white" : "bg-surface-raised text-text-primary hover:bg-surface-sunken" + }`} + > + + {suggestion.ticker} + + {suggestion.exchange} + + + {suggestion.name} + + ); + })} + + )} + {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 ? ( handleExchangeChange(i, e.target.value)} > {opts.map((o) => ( @@ -558,13 +568,13 @@ export default function PortfolioPage() { className="w-24 bg-bg-primary border border-accent-blue rounded px-2 py-1" /> ) : ( - <>${p.avg_price.toFixed(2)}> + <>{formatCurrencyValue(convertAmount(p.avg_price, srcCostCurrency, displayCurrency), displayCurrency)}> )} {formatCurrencyValue(convertAmount(price, srcValueCurrency, displayCurrency), displayCurrency)} {formatCurrencyValue(value, displayCurrency)} = 0 ? "text-accent-green" : "text-accent-red"}`}> - {gl >= 0 ? "+" : ""}{formatCurrencyValue(Math.abs(gl), displayCurrency)} + {gl >= 0 ? "+" : "-"}{formatCurrencyValue(Math.abs(gl), displayCurrency)} = 0 ? "text-accent-green" : "text-accent-red"}`}> {glPct >= 0 ? "+" : ""}{glPct.toFixed(1)}% diff --git a/atlas-terminal/apps/web/src/app/report/page.tsx b/atlas-terminal/apps/web/src/app/report/page.tsx index d39e901..8a92797 100644 --- a/atlas-terminal/apps/web/src/app/report/page.tsx +++ b/atlas-terminal/apps/web/src/app/report/page.tsx @@ -1503,16 +1503,59 @@ export default function ReportPage() { .report-section li { margin-left: 16px; list-style: disc; } @media print { - body { background: white !important; -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; } + html, body { + width: 210mm !important; + min-width: 210mm !important; + margin: 0 !important; + padding: 0 !important; + background: white !important; + -webkit-print-color-adjust: exact !important; + print-color-adjust: exact !important; + } .no-print, nav, aside, header, [class*="sidebar"], [class*="ticker-bar"], [class*="chat-panel"], [class*="app-shell"] > :first-child, [class*="app-shell"] > :last-child { display: none !important; } [class*="app-shell"] { display: block !important; } - [class*="app-shell"] > :nth-child(2) { margin: 0 !important; padding: 0 !important; width: 100% !important; max-width: 100% !important; } - .report-container { max-width: 100% !important; margin: 0 !important; } - .report-page { page-break-after: always; page-break-inside: avoid; margin: 0; padding: 28px 36px; border-radius: 0; } - .cover-page { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; } - .report-section { page-break-inside: avoid; } - @page { size: A4; margin: 12mm 10mm; } + [class*="app-shell"] > :nth-child(2) { + margin: 0 !important; + padding: 0 !important; + width: 210mm !important; + max-width: 210mm !important; + min-width: 210mm !important; + background: white !important; + } + .report-container { + width: 210mm !important; + max-width: 210mm !important; + min-width: 210mm !important; + margin: 0 !important; + padding: 0 !important; + } + .report-page { + box-sizing: border-box !important; + width: 210mm !important; + min-height: 297mm !important; + page-break-after: always; + break-after: page; + page-break-inside: avoid; + break-inside: avoid; + margin: 0 !important; + padding: 8mm 10mm 9mm !important; + border-radius: 0 !important; + overflow: hidden !important; + } + .report-page:last-child { + page-break-after: auto; + break-after: auto; + } + .cover-page { + min-height: 297mm !important; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + } + .report-section { page-break-inside: avoid; break-inside: avoid; } + @page { size: A4 portrait; margin: 0; } } `} > diff --git a/atlas-terminal/apps/web/src/app/research/page.tsx b/atlas-terminal/apps/web/src/app/research/page.tsx index 4d375ec..09faedf 100644 --- a/atlas-terminal/apps/web/src/app/research/page.tsx +++ b/atlas-terminal/apps/web/src/app/research/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { ResearchGridLayout } from "../components/research/ResearchGridLayout"; +import dynamic from "next/dynamic"; import type { ResearchDashboardPayload } from "../components/research/types"; import { ErrorBanner } from "../components/ui/ErrorBanner"; import { LoadingPulse } from "../components/ui/LoadingPulse"; @@ -8,21 +8,32 @@ import { SectionHeading } from "../components/ui/SectionHeading"; import { useApi } from "../lib/use-api"; import { useTicker } from "../lib/use-ticker"; -interface OverviewResp { +const ResearchGridLayout = dynamic<{ dashboard: ResearchDashboardPayload }>( + () => import("../components/research/ResearchGridLayout").then((mod) => mod.ResearchGridLayout), + { + ssr: false, + loading: () => , + }, +); + +interface AssetTypeResp { asset_type?: string; } export default function ResearchPage() { const { ticker, initialized } = useTicker(); - const overviewUrl = initialized ? `/api/market/overview/${ticker}` : null; - const dashUrl = initialized ? `/api/research/dashboard/${encodeURIComponent(ticker)}` : null; + const assetUrl = initialized ? `/api/market/asset-type/${ticker}` : null; + const asset = useApi(assetUrl, { cacheTtlMs: 5 * 60_000 }); - const overview = useApi(overviewUrl); + const assetType = asset.data?.asset_type || "equity"; + const dashUrl = + initialized && !asset.loading && assetType === "equity" + ? `/api/research/dashboard/${encodeURIComponent(ticker)}` + : null; const dashboard = useApi(dashUrl); - const assetType = overview.data?.asset_type || "equity"; - const loading = overview.loading || dashboard.loading; + const loading = asset.loading || (assetType === "equity" && dashboard.loading); if (!initialized || loading) { return ; diff --git a/atlas-terminal/apps/web/src/hooks/use-keyboard-shortcuts.ts b/atlas-terminal/apps/web/src/hooks/use-keyboard-shortcuts.ts new file mode 100644 index 0000000..67ddeef --- /dev/null +++ b/atlas-terminal/apps/web/src/hooks/use-keyboard-shortcuts.ts @@ -0,0 +1,72 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { tinykeys, type TinyKeysHandler } from "tinykeys"; +import { useTerminal } from "@/stores/terminal"; + +function isEditableTarget(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) return false; + const tag = target.tagName.toLowerCase(); + return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable; +} + +function focusElement(selector: string) { + const el = document.querySelector(selector); + el?.focus(); +} + +export function useKeyboardShortcuts() { + const router = useRouter(); + const activeSymbol = useTerminal((state) => state.activeSymbol); + const addToWatchlist = useTerminal((state) => state.addToWatchlist); + + useEffect(() => { + const focusTickerSearch: TinyKeysHandler = (event) => { + event.preventDefault(); + focusElement("#atlas-ticker-search"); + }; + + const focusTickerSearchWhenIdle: TinyKeysHandler = (event) => { + if (isEditableTarget(event.target)) return; + event.preventDefault(); + focusElement("#atlas-ticker-search"); + }; + + const focusCopilotWhenIdle: TinyKeysHandler = (event) => { + if (isEditableTarget(event.target)) return; + event.preventDefault(); + focusElement("#atlas-copilot-input"); + }; + + const addCurrentSymbolToWatchlist: TinyKeysHandler = (event) => { + if (isEditableTarget(event.target)) return; + event.preventDefault(); + addToWatchlist(activeSymbol || undefined); + }; + + const navigateWhenIdle = (href: string): TinyKeysHandler => (event) => { + if (isEditableTarget(event.target)) return; + event.preventDefault(); + router.push(href); + }; + + const dispatchTabShortcut = (index: number): TinyKeysHandler => (event) => { + if (isEditableTarget(event.target)) return; + window.dispatchEvent(new CustomEvent("atlas-terminal-tab-shortcut", { detail: { index } })); + }; + + return tinykeys(window, { + "$mod+KeyK": focusTickerSearch, + KeyG: focusTickerSearchWhenIdle, + Slash: focusCopilotWhenIdle, + KeyW: addCurrentSymbolToWatchlist, + KeyP: navigateWhenIdle("/portfolio"), + KeyM: navigateWhenIdle("/macro"), + KeyN: navigateWhenIdle("/news"), + Digit1: dispatchTabShortcut(0), + Digit2: dispatchTabShortcut(1), + Digit3: dispatchTabShortcut(2), + }); + }, [activeSymbol, addToWatchlist, router]); +} diff --git a/atlas-terminal/apps/web/src/stores/terminal.ts b/atlas-terminal/apps/web/src/stores/terminal.ts new file mode 100644 index 0000000..c07d095 --- /dev/null +++ b/atlas-terminal/apps/web/src/stores/terminal.ts @@ -0,0 +1,165 @@ +"use client"; + +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { normalizeTickerInput } from "@/app/lib/ticker-alias"; + +export type Currency = "USD" | "EUR" | "KRW" | "JPY" | "DKK" | "GBP"; +export type TerminalTheme = "bloomberg" | "minimal"; +export type TerminalPage = + | "equity" + | "portfolio" + | "macro" + | "news" + | "screener" + | "research" + | "valuation" + | "technical" + | "markets" + | "earnings" + | "filings" + | "report" + | "settings"; + +export interface GridLayout { + columns?: number; + rows?: number; + widgets?: string[]; + [key: string]: unknown; +} + +export interface CopilotContext { + activeSymbol: string | null; + activePage: TerminalPage; + recentSymbols: string[]; + currency: Currency; + theme: TerminalTheme; + watchlist: string[]; +} + +interface PersistedTerminalState { + activeSymbol: string | null; + activePage: TerminalPage; + recentSymbols: string[]; + currency: Currency; + theme: TerminalTheme; + watchlist: string[]; + layouts: Record; +} + +export interface TerminalState extends PersistedTerminalState { + hydrated: boolean; + buildCopilotContext: () => CopilotContext; + setHydrated: (hydrated: boolean) => void; + setActiveSymbol: (symbol: string) => void; + setActivePage: (page: TerminalPage) => void; + setCurrency: (currency: Currency) => void; + setTheme: (theme: TerminalTheme) => void; + addToWatchlist: (symbol?: string) => void; + removeFromWatchlist: (symbol: string) => void; + setLayout: (key: string, layout: GridLayout) => void; +} + +const DEFAULT_SYMBOL = "AAPL"; +const MAX_RECENT_SYMBOLS = 10; + +function normalizeSymbol(symbol: string): string { + return normalizeTickerInput(symbol); +} + +function nextRecentSymbols(symbol: string, recentSymbols: string[]) { + return [symbol, ...recentSymbols.filter((item) => item !== symbol)].slice(0, MAX_RECENT_SYMBOLS); +} + +export function terminalPageFromPathname(pathname: string): TerminalPage { + const firstSegment = pathname.split("/").filter(Boolean)[0] || ""; + if (firstSegment === "portfolio") return "portfolio"; + if (firstSegment === "macro") return "macro"; + if (firstSegment === "news") return "news"; + if (firstSegment === "screener") return "screener"; + if (firstSegment === "research") return "research"; + if (firstSegment === "valuation") return "valuation"; + if (firstSegment === "technical") return "technical"; + if (firstSegment === "markets") return "markets"; + if (firstSegment === "earnings") return "earnings"; + if (firstSegment === "filings") return "filings"; + if (firstSegment === "report") return "report"; + if (firstSegment === "settings") return "settings"; + return "equity"; +} + +export const useTerminal = create()( + persist( + (set, get) => ({ + activeSymbol: DEFAULT_SYMBOL, + activePage: "equity", + recentSymbols: [DEFAULT_SYMBOL], + currency: "USD", + theme: "bloomberg", + watchlist: [], + layouts: {}, + hydrated: false, + + buildCopilotContext: () => { + const state = get(); + return { + activeSymbol: state.activeSymbol, + activePage: state.activePage, + recentSymbols: state.recentSymbols, + currency: state.currency, + theme: state.theme, + watchlist: state.watchlist, + }; + }, + + setHydrated: (hydrated) => set({ hydrated }), + + setActiveSymbol: (symbol) => { + const normalized = normalizeSymbol(symbol); + if (!normalized) return; + set((state) => ({ + activeSymbol: normalized, + recentSymbols: nextRecentSymbols(normalized, state.recentSymbols), + })); + }, + + setActivePage: (page) => set({ activePage: page }), + setCurrency: (currency) => set({ currency }), + setTheme: (theme) => set({ theme }), + + addToWatchlist: (symbol) => { + const normalized = normalizeSymbol(symbol || get().activeSymbol || ""); + if (!normalized) return; + set((state) => ({ + watchlist: state.watchlist.includes(normalized) ? state.watchlist : [...state.watchlist, normalized], + })); + }, + + removeFromWatchlist: (symbol) => { + const normalized = normalizeSymbol(symbol); + set((state) => ({ watchlist: state.watchlist.filter((item) => item !== normalized) })); + }, + + setLayout: (key, layout) => { + set((state) => ({ layouts: { ...state.layouts, [key]: layout } })); + }, + }), + { + name: "atlas-terminal", + version: 1, + storage: createJSONStorage(() => localStorage), + partialize: (state): PersistedTerminalState => ({ + activeSymbol: state.activeSymbol, + activePage: state.activePage, + recentSymbols: state.recentSymbols, + currency: state.currency, + theme: state.theme, + watchlist: state.watchlist, + layouts: state.layouts, + }), + onRehydrateStorage: () => (state) => { + state?.setHydrated(true); + }, + }, + ), +); diff --git a/atlas-terminal/apps/web/src/types/tinykeys.d.ts b/atlas-terminal/apps/web/src/types/tinykeys.d.ts new file mode 100644 index 0000000..f6ce894 --- /dev/null +++ b/atlas-terminal/apps/web/src/types/tinykeys.d.ts @@ -0,0 +1,10 @@ +declare module "tinykeys" { + export type TinyKeysHandler = (event: KeyboardEvent) => void; + export type TinyKeysKeyBindingMap = Record; + + export function tinykeys( + target: Window | Document | HTMLElement, + keyBindingMap: TinyKeysKeyBindingMap, + options?: { event?: string; capture?: boolean }, + ): () => void; +} diff --git a/atlas-terminal/apps/web/tsconfig.json b/atlas-terminal/apps/web/tsconfig.json index 7b28589..64c0041 100644 --- a/atlas-terminal/apps/web/tsconfig.json +++ b/atlas-terminal/apps/web/tsconfig.json @@ -1,6 +1,10 @@ { "compilerOptions": { - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -18,9 +22,19 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next-dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] } diff --git a/atlas-terminal/docs/baseline-2026-04.md b/atlas-terminal/docs/baseline-2026-04.md new file mode 100644 index 0000000..5fee5da --- /dev/null +++ b/atlas-terminal/docs/baseline-2026-04.md @@ -0,0 +1,65 @@ +# ATLAS Terminal Baseline — April 2026 + +Captured on 2026-04-21 before the v2 Data Gateway migration. + +## Environment + +- Backend: FastAPI on `127.0.0.1:8000` +- Frontend: Next.js dev server on `localhost:3000` +- Database: SQLite +- Browser baseline: `agent-browser` Chromium session + +## Frontend Timing + +Local overview route `/` after network idle: + +| Metric | Value | +| --- | ---: | +| DOM interactive | 171 ms | +| DOMContentLoaded | 172 ms | +| First paint | 284 ms | +| First contentful paint | 284 ms | +| Load event end | 394 ms | +| Resource count | 26 | + +Lighthouse was not run in this pass because the repo does not currently include a Lighthouse dependency or script. The browser navigation timing above is the baseline until Lighthouse is added. + +## API Latency Samples + +Measured with `curl` against the live local backend. Values are seconds. + +| Endpoint | Samples | Approx p50 | Approx p95 | Notes | +| --- | --- | ---: | ---: | --- | +| `/api/health` | 0.0014, 0.0024, 0.0010, 0.0010 | 0.0012 | 0.0024 | Stable local health check | +| `/api/market/indices` | 0.7092, 0.8025, 0.2898, 0.3265 | 0.518 | 0.8025 | yfinance-backed and still provider-bound | +| `/api/market/asset-type/AAPL` | 0.2486, 0.1845, 0.2014, 0.1920 | 0.2014 | 0.2486 | Lightweight ticker classification | +| `/api/portfolio/summary` | 0.2929, 0.0019, 0.0017, 0.0018 | 0.0019 | 0.2929 | Warm cache makes repeat calls near-instant | +| `/api/fx/rates` | 0.1722, 0.0012, 0.0010, 0.0012 | 0.0012 | 0.1722 | Warm cache makes repeat calls near-instant | + +## Bundle Size Baseline + +Latest `npm run build` route output: + +| Route | Size | First Load JS | +| --- | ---: | ---: | +| `/` | 6.35 kB | 98.2 kB | +| `/earnings` | 4.31 kB | 92 kB | +| `/filings` | 8.24 kB | 96 kB | +| `/macro` | 16 kB | 207 kB | +| `/markets` | 7.03 kB | 94.8 kB | +| `/news` | 5.43 kB | 93.2 kB | +| `/portfolio` | 5.93 kB | 93.7 kB | +| `/report` | 21.7 kB | 213 kB | +| `/research` | 2.73 kB | 94.6 kB | +| `/screener` | 3.26 kB | 91 kB | +| `/settings` | 2.57 kB | 90.3 kB | +| `/technical` | 6.38 kB | 94.1 kB | +| `/valuation` | 5.1 kB | 97 kB | + +Shared first-load JS: 87.7 kB. + +## FMP Call Baseline + +Runtime FMP call volume is not yet centrally instrumented. Static inspection shows FMP access still lives behind `server/services/fmp_client.py`, but daily call count cannot be measured reliably until Phase 1 routes provider traffic through the Data Gateway. + +Baseline tracking target for Phase 1: add provider-level counters at the gateway seam and compare FMP calls before/after each migrated endpoint. diff --git a/atlas-terminal/server/core/__init__.py b/atlas-terminal/server/core/__init__.py new file mode 100644 index 0000000..8ee626d --- /dev/null +++ b/atlas-terminal/server/core/__init__.py @@ -0,0 +1,29 @@ +"""Core infrastructure for ATLAS Terminal v2 refactors.""" + +from server.core.chained_gateway import ChainedGateway +from server.core.cache import CachedGateway +from server.core.data_gateway import ( + Article, + DataGateway, + EarningEvent, + Fundamentals, + HoldersData, + OHLCV, + Profile, + Quote, + Segment, +) + +__all__ = [ + "Article", + "CachedGateway", + "ChainedGateway", + "DataGateway", + "EarningEvent", + "Fundamentals", + "HoldersData", + "OHLCV", + "Profile", + "Quote", + "Segment", +] diff --git a/atlas-terminal/server/core/cache.py b/atlas-terminal/server/core/cache.py new file mode 100644 index 0000000..fd61ab7 --- /dev/null +++ b/atlas-terminal/server/core/cache.py @@ -0,0 +1,87 @@ +"""TTL cache wrapper for DataGateway implementations.""" + +from __future__ import annotations + +import asyncio +import time +from collections.abc import Awaitable, Callable +from typing import Any, TypeVar + +from server.core.data_gateway import Article, DataGateway, EarningEvent, Fundamentals, HoldersData, OHLCV, Profile, Quote, Segment + +T = TypeVar("T") + + +class CachedGateway(DataGateway): + """Process-local cache for expensive provider calls. + + This is deliberately thin and replaceable. The contract gives us a single + seam where Redis/Vercel Runtime Cache/Supabase cache can later slot in + without rewriting routers. + """ + + TTLS = { + "quote": 30, + "profile": 86_400, + "fundamentals": 43_200, + "segments": 604_800, + "history": 300, + "news": 300, + "peers": 86_400, + "holders": 43_200, + "earnings_calendar": 3_600, + } + + def __init__(self, inner: DataGateway) -> None: + self.inner = inner + self._lock = asyncio.Lock() + self._store: dict[str, tuple[float, Any]] = {} + + def _key(self, method: str, *parts: Any) -> str: + normalized = ":".join(str(part).strip().upper() for part in parts) + return f"{method}:{normalized}" + + async def _cached(self, method: str, key_parts: tuple[Any, ...], fetcher: Callable[[], Awaitable[T]]) -> T: + key = self._key(method, *key_parts) + ttl = self.TTLS[method] + now = time.monotonic() + async with self._lock: + cached = self._store.get(key) + if cached and now - cached[0] < ttl: + return cached[1] + + result = await fetcher() + async with self._lock: + self._store[key] = (time.monotonic(), result) + return result + + async def quote(self, symbol: str) -> Quote: + return await self._cached("quote", (symbol,), lambda: self.inner.quote(symbol)) + + async def profile(self, symbol: str) -> Profile: + return await self._cached("profile", (symbol,), lambda: self.inner.profile(symbol)) + + async def fundamentals(self, symbol: str, period: str = "annual") -> Fundamentals: + return await self._cached("fundamentals", (symbol, period), lambda: self.inner.fundamentals(symbol, period)) + + async def history(self, symbol: str, range: str = "1y") -> OHLCV: + return await self._cached("history", (symbol, range), lambda: self.inner.history(symbol, range)) + + async def news(self, symbols: list[str], limit: int = 20) -> list[Article]: + return await self._cached("news", (",".join(symbols), limit), lambda: self.inner.news(symbols, limit)) + + async def peers(self, symbol: str) -> list[str]: + return await self._cached("peers", (symbol,), lambda: self.inner.peers(symbol)) + + async def segments(self, symbol: str) -> list[Segment]: + return await self._cached("segments", (symbol,), lambda: self.inner.segments(symbol)) + + async def holders(self, symbol: str) -> HoldersData: + return await self._cached("holders", (symbol,), lambda: self.inner.holders(symbol)) + + async def earnings_calendar(self, symbol: str) -> list[EarningEvent]: + return await self._cached("earnings_calendar", (symbol,), lambda: self.inner.earnings_calendar(symbol)) + + async def clear(self) -> None: + async with self._lock: + self._store.clear() diff --git a/atlas-terminal/server/core/chained_gateway.py b/atlas-terminal/server/core/chained_gateway.py new file mode 100644 index 0000000..c97d221 --- /dev/null +++ b/atlas-terminal/server/core/chained_gateway.py @@ -0,0 +1,77 @@ +"""Chain-of-responsibility gateway implementation.""" + +from __future__ import annotations + +from typing import Awaitable, Callable, TypeVar + +from server.core.data_gateway import Article, DataGateway, EarningEvent, Fundamentals, HoldersData, OHLCV, Profile, Quote, Segment +from server.core.provider_metrics import provider_metrics +from server.core.providers.base import BaseProvider, DataUnavailable, ProviderError + +T = TypeVar("T") + + +class ChainedGateway(DataGateway): + """Try providers in order until one returns usable data.""" + + def __init__(self, providers: list[BaseProvider]) -> None: + self.providers = providers + + def _order_for(self, symbol: str) -> list[BaseProvider]: + normalized = symbol.strip().upper() + supported = [provider for provider in self.providers if provider.supports_symbol(normalized)] + korean = normalized.endswith(".KS") or normalized.endswith(".KQ") or normalized[:6].isdigit() + + if not korean: + return supported + + # KIS gets first shot at Korean tickers when present; otherwise preserve + # configured order. This keeps the rule declarative without hard-coding + # imports here. + return sorted(supported, key=lambda provider: 0 if provider.name == "kis" else 1) + + async def _try(self, symbol: str, method: str, call: Callable[[BaseProvider], Awaitable[T]]) -> T: + errors: list[str] = [] + for provider in self._order_for(symbol): + provider_metrics.record_attempt(provider.name, method) + try: + result = await call(provider) + provider_metrics.record_success(provider.name, method) + return result + except ProviderError as exc: + provider_metrics.record_failure(provider.name, method) + errors.append(f"{provider.name}: {exc}") + continue + except Exception as exc: + provider_metrics.record_failure(provider.name, method) + errors.append(f"{provider.name}: unexpected {type(exc).__name__}: {exc}") + continue + raise DataUnavailable(symbol, method, errors) + + async def quote(self, symbol: str) -> Quote: + return await self._try(symbol, "quote", lambda provider: provider.quote(symbol)) + + async def profile(self, symbol: str) -> Profile: + return await self._try(symbol, "profile", lambda provider: provider.profile(symbol)) + + async def fundamentals(self, symbol: str, period: str = "annual") -> Fundamentals: + return await self._try(symbol, "fundamentals", lambda provider: provider.fundamentals(symbol, period)) + + async def history(self, symbol: str, range: str = "1y") -> OHLCV: + return await self._try(symbol, "history", lambda provider: provider.history(symbol, range)) + + async def news(self, symbols: list[str], limit: int = 20) -> list[Article]: + key = ",".join(symbols) + return await self._try(key, "news", lambda provider: provider.news(symbols, limit)) + + async def peers(self, symbol: str) -> list[str]: + return await self._try(symbol, "peers", lambda provider: provider.peers(symbol)) + + async def segments(self, symbol: str) -> list[Segment]: + return await self._try(symbol, "segments", lambda provider: provider.segments(symbol)) + + async def holders(self, symbol: str) -> HoldersData: + return await self._try(symbol, "holders", lambda provider: provider.holders(symbol)) + + async def earnings_calendar(self, symbol: str) -> list[EarningEvent]: + return await self._try(symbol, "earnings_calendar", lambda provider: provider.earnings_calendar(symbol)) diff --git a/atlas-terminal/server/core/data_gateway.py b/atlas-terminal/server/core/data_gateway.py new file mode 100644 index 0000000..dd38d3e --- /dev/null +++ b/atlas-terminal/server/core/data_gateway.py @@ -0,0 +1,134 @@ +"""Typed data gateway contract for market data access. + +This module is intentionally provider-agnostic. Routers should eventually +depend on this Protocol instead of reaching into yfinance/FMP/yahooquery +directly. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date, datetime +from typing import Any, Literal, Protocol + + +@dataclass(frozen=True) +class Quote: + symbol: str + price: float | None + currency: str | None = None + change: float | None = None + change_pct: float | None = None + market_time: datetime | None = None + source: str = "" + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class Profile: + symbol: str + name: str | None = None + description: str | None = None + sector: str | None = None + industry: str | None = None + country: str | None = None + exchange: str | None = None + currency: str | None = None + website: str | None = None + employees: int | None = None + market_cap: float | None = None + source: str = "" + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class Fundamentals: + symbol: str + period: str + revenue: float | None = None + gross_profit: float | None = None + operating_income: float | None = None + net_income: float | None = None + ebitda: float | None = None + free_cash_flow: float | None = None + total_assets: float | None = None + total_debt: float | None = None + cash: float | None = None + shares: float | None = None + source: str = "" + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class OHLCVBar: + date: date + open: float | None + high: float | None + low: float | None + close: float | None + volume: float | None = None + + +@dataclass(frozen=True) +class OHLCV: + symbol: str + range: str + bars: list[OHLCVBar] + source: str = "" + + +@dataclass(frozen=True) +class Article: + title: str + url: str + source: str | None = None + published_at: datetime | None = None + summary: str | None = None + symbols: list[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class Segment: + name: str + revenue: float | None = None + percent: float | None = None + period: str | None = None + + +@dataclass(frozen=True) +class HoldersData: + symbol: str + institutions: list[dict[str, Any]] = field(default_factory=list) + insiders: list[dict[str, Any]] = field(default_factory=list) + source: str = "" + + +@dataclass(frozen=True) +class EarningEvent: + symbol: str + event_date: date + fiscal_quarter: str | None = None + eps_estimate: float | None = None + revenue_estimate: float | None = None + status: Literal["confirmed", "estimated"] = "estimated" + source: str = "" + + +class DataGateway(Protocol): + async def quote(self, symbol: str) -> Quote: ... + + async def profile(self, symbol: str) -> Profile: ... + + async def fundamentals(self, symbol: str, period: str = "annual") -> Fundamentals: ... + + async def history(self, symbol: str, range: str = "1y") -> OHLCV: ... + + async def news(self, symbols: list[str], limit: int = 20) -> list[Article]: ... + + async def peers(self, symbol: str) -> list[str]: ... + + async def segments(self, symbol: str) -> list[Segment]: ... + + async def holders(self, symbol: str) -> HoldersData: ... + + async def earnings_calendar(self, symbol: str) -> list[EarningEvent]: ... diff --git a/atlas-terminal/server/core/factory.py b/atlas-terminal/server/core/factory.py new file mode 100644 index 0000000..80e0aeb --- /dev/null +++ b/atlas-terminal/server/core/factory.py @@ -0,0 +1,31 @@ +"""Factory for the v2 Data Gateway stack.""" + +from __future__ import annotations + +from functools import lru_cache + +from server.core.cache import CachedGateway +from server.core.chained_gateway import ChainedGateway +from server.core.data_gateway import DataGateway +from server.core.providers import FMPProvider, KISProvider, YahooQueryProvider, YFinanceProvider + + +@lru_cache(maxsize=1) +def get_data_gateway() -> DataGateway: + """Return the process-wide gateway instance. + + Provider order is declarative: KIS can win for Korean tickers via + ChainedGateway._order_for, while FMP remains the default first provider for + globally listed equities when configured. + """ + + return CachedGateway( + ChainedGateway( + [ + FMPProvider(), + KISProvider(), + YahooQueryProvider(), + YFinanceProvider(), + ] + ) + ) diff --git a/atlas-terminal/server/core/flags.py b/atlas-terminal/server/core/flags.py new file mode 100644 index 0000000..173d588 --- /dev/null +++ b/atlas-terminal/server/core/flags.py @@ -0,0 +1,15 @@ +"""Server-side feature flags for staged refactors.""" + +from __future__ import annotations + +import os + + +def _enabled(name: str) -> bool: + return (os.getenv(name) or "").strip().lower() in {"1", "true", "yes", "on"} + + +def new_data_gateway_enabled() -> bool: + """Gate router migrations onto the v2 Data Gateway.""" + + return _enabled("ATLAS_FLAG_GATEWAY") diff --git a/atlas-terminal/server/core/provider_metrics.py b/atlas-terminal/server/core/provider_metrics.py new file mode 100644 index 0000000..aa8eee7 --- /dev/null +++ b/atlas-terminal/server/core/provider_metrics.py @@ -0,0 +1,59 @@ +"""In-process provider counters for Data Gateway migration measurements.""" + +from __future__ import annotations + +import threading +from collections import defaultdict +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ProviderMetricRow: + provider: str + method: str + attempts: int + successes: int + failures: int + + +class ProviderMetrics: + def __init__(self) -> None: + self._lock = threading.Lock() + self._attempts: defaultdict[tuple[str, str], int] = defaultdict(int) + self._successes: defaultdict[tuple[str, str], int] = defaultdict(int) + self._failures: defaultdict[tuple[str, str], int] = defaultdict(int) + + def record_attempt(self, provider: str, method: str) -> None: + with self._lock: + self._attempts[(provider, method)] += 1 + + def record_success(self, provider: str, method: str) -> None: + with self._lock: + self._successes[(provider, method)] += 1 + + def record_failure(self, provider: str, method: str) -> None: + with self._lock: + self._failures[(provider, method)] += 1 + + def snapshot(self) -> list[ProviderMetricRow]: + with self._lock: + keys = set(self._attempts) | set(self._successes) | set(self._failures) + return [ + ProviderMetricRow( + provider=provider, + method=method, + attempts=self._attempts[(provider, method)], + successes=self._successes[(provider, method)], + failures=self._failures[(provider, method)], + ) + for provider, method in sorted(keys) + ] + + def clear(self) -> None: + with self._lock: + self._attempts.clear() + self._successes.clear() + self._failures.clear() + + +provider_metrics = ProviderMetrics() diff --git a/atlas-terminal/server/core/providers/__init__.py b/atlas-terminal/server/core/providers/__init__.py new file mode 100644 index 0000000..33db608 --- /dev/null +++ b/atlas-terminal/server/core/providers/__init__.py @@ -0,0 +1,19 @@ +"""Data Gateway provider implementations.""" + +from server.core.providers.base import BaseProvider, DataUnavailable, ProviderError, ProviderNotConfigured, ProviderNotImplemented +from server.core.providers.fmp import FMPProvider +from server.core.providers.kis import KISProvider +from server.core.providers.yahooquery import YahooQueryProvider +from server.core.providers.yfinance import YFinanceProvider + +__all__ = [ + "BaseProvider", + "DataUnavailable", + "FMPProvider", + "KISProvider", + "ProviderError", + "ProviderNotConfigured", + "ProviderNotImplemented", + "YahooQueryProvider", + "YFinanceProvider", +] diff --git a/atlas-terminal/server/core/providers/base.py b/atlas-terminal/server/core/providers/base.py new file mode 100644 index 0000000..03195b8 --- /dev/null +++ b/atlas-terminal/server/core/providers/base.py @@ -0,0 +1,74 @@ +"""Provider primitives for the ATLAS Data Gateway.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Callable, TypeVar + +from server.core.data_gateway import Article, EarningEvent, Fundamentals, HoldersData, OHLCV, Profile, Quote, Segment + +T = TypeVar("T") +logger = logging.getLogger(__name__) + + +class ProviderError(Exception): + """Base class for recoverable provider failures.""" + + +class ProviderNotImplemented(ProviderError): + """Provider does not implement this data shape yet.""" + + +class ProviderNotConfigured(ProviderError): + """Provider requires credentials or environment that are not configured.""" + + +class DataUnavailable(Exception): + """Raised after every provider in a chain fails.""" + + def __init__(self, symbol: str, method: str, errors: list[str] | None = None) -> None: + self.symbol = symbol + self.method = method + self.errors = errors or [] + suffix = f": {'; '.join(self.errors)}" if self.errors else "" + super().__init__(f"Data unavailable for {symbol} via {method}{suffix}") + + +class BaseProvider: + """Small async facade around concrete market-data providers.""" + + name = "base" + + def supports_symbol(self, symbol: str) -> bool: + return bool(symbol.strip()) + + async def _to_thread(self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T: + return await asyncio.to_thread(fn, *args, **kwargs) + + async def quote(self, symbol: str) -> Quote: + raise ProviderNotImplemented(f"{self.name}.quote") + + async def profile(self, symbol: str) -> Profile: + raise ProviderNotImplemented(f"{self.name}.profile") + + async def fundamentals(self, symbol: str, period: str = "annual") -> Fundamentals: + raise ProviderNotImplemented(f"{self.name}.fundamentals") + + async def history(self, symbol: str, range: str = "1y") -> OHLCV: + raise ProviderNotImplemented(f"{self.name}.history") + + async def news(self, symbols: list[str], limit: int = 20) -> list[Article]: + raise ProviderNotImplemented(f"{self.name}.news") + + async def peers(self, symbol: str) -> list[str]: + raise ProviderNotImplemented(f"{self.name}.peers") + + async def segments(self, symbol: str) -> list[Segment]: + raise ProviderNotImplemented(f"{self.name}.segments") + + async def holders(self, symbol: str) -> HoldersData: + raise ProviderNotImplemented(f"{self.name}.holders") + + async def earnings_calendar(self, symbol: str) -> list[EarningEvent]: + raise ProviderNotImplemented(f"{self.name}.earnings_calendar") diff --git a/atlas-terminal/server/core/providers/fmp.py b/atlas-terminal/server/core/providers/fmp.py new file mode 100644 index 0000000..7f54145 --- /dev/null +++ b/atlas-terminal/server/core/providers/fmp.py @@ -0,0 +1,61 @@ +"""Financial Modeling Prep provider for the ATLAS Data Gateway.""" + +from __future__ import annotations + +from server.core.data_gateway import Profile, Quote, Segment +from server.core.providers.base import BaseProvider, ProviderError, ProviderNotConfigured, ProviderNotImplemented +from server.services import fmp_client + + +class FMPProvider(BaseProvider): + name = "fmp" + + async def _get_json(self, path: str, params: dict[str, object]) -> object: + if not fmp_client.fmp_is_configured(): + raise ProviderNotConfigured("FMP_API_KEY is not set") + data = await fmp_client._fmp_get_json(path, params) # noqa: SLF001 - temporary bridge until public FMP gateway helpers exist. + if data is None: + raise ProviderError(f"empty response for {path}") + return data + + async def quote(self, symbol: str) -> Quote: + normalized = symbol.strip().upper() + data = await self._get_json(f"/quote/{normalized}", {}) + row = data[0] if isinstance(data, list) and data else None + if not isinstance(row, dict): + raise ProviderError("missing quote row") + price = row.get("price") + return Quote( + symbol=normalized, + price=float(price) if isinstance(price, (int, float)) else None, + change=row.get("change"), + change_pct=row.get("changesPercentage"), + source=self.name, + raw=row, + ) + + async def profile(self, symbol: str) -> Profile: + normalized = symbol.strip().upper() + data = await self._get_json(f"/profile/{normalized}", {}) + row = data[0] if isinstance(data, list) and data else None + if not isinstance(row, dict): + raise ProviderError("missing profile row") + hq = ", ".join(str(row.get(key)) for key in ("city", "state", "country") if row.get(key)) + return Profile( + symbol=normalized, + name=row.get("companyName"), + description=row.get("description"), + sector=row.get("sector"), + industry=row.get("industry"), + country=row.get("country"), + exchange=row.get("exchangeShortName") or row.get("exchange"), + currency=row.get("currency"), + website=row.get("website"), + employees=row.get("fullTimeEmployees"), + market_cap=row.get("mktCap"), + source=self.name, + raw={**row, "hq": hq}, + ) + + async def segments(self, symbol: str) -> list[Segment]: + raise ProviderNotImplemented("FMP segments parser is planned for Phase 1.4") diff --git a/atlas-terminal/server/core/providers/kis.py b/atlas-terminal/server/core/providers/kis.py new file mode 100644 index 0000000..433aa43 --- /dev/null +++ b/atlas-terminal/server/core/providers/kis.py @@ -0,0 +1,28 @@ +"""Korea Investment Securities provider placeholder. + +KIS should become the first provider for Korean equities once credentials and +the concrete client are wired. Keeping it as a provider now lets the chain +ordering and feature-flagged migration land without touching routers. +""" + +from __future__ import annotations + +import os + +from server.core.providers.base import BaseProvider, ProviderNotConfigured + + +class KISProvider(BaseProvider): + name = "kis" + + def supports_symbol(self, symbol: str) -> bool: + normalized = symbol.strip().upper() + return normalized.endswith(".KS") or normalized.endswith(".KQ") or normalized[:6].isdigit() + + def _configured(self) -> bool: + return bool(os.getenv("KIS_APP_KEY") and os.getenv("KIS_APP_SECRET")) + + async def quote(self, symbol: str): # type: ignore[no-untyped-def] + if not self._configured(): + raise ProviderNotConfigured("KIS credentials are not set") + raise ProviderNotConfigured("KIS client is not wired yet") diff --git a/atlas-terminal/server/core/providers/yahooquery.py b/atlas-terminal/server/core/providers/yahooquery.py new file mode 100644 index 0000000..6cc2c69 --- /dev/null +++ b/atlas-terminal/server/core/providers/yahooquery.py @@ -0,0 +1,66 @@ +"""YahooQuery provider for the ATLAS Data Gateway.""" + +from __future__ import annotations + +from typing import Any + +from server.core.data_gateway import Profile, Quote +from server.core.providers.base import BaseProvider, ProviderError + + +class YahooQueryProvider(BaseProvider): + name = "yahooquery" + + def _ticker(self, symbol: str) -> Any: + try: + from yahooquery import Ticker + except Exception as exc: # pragma: no cover - dependency is expected in app runtime + raise ProviderError(f"yahooquery import failed: {exc}") from exc + return Ticker(symbol.strip().upper()) + + async def quote(self, symbol: str) -> Quote: + def fetch() -> Quote: + normalized = symbol.strip().upper() + data = self._ticker(normalized).price + row = data.get(normalized) if isinstance(data, dict) else None + if not isinstance(row, dict): + raise ProviderError("missing price payload") + price = row.get("regularMarketPrice") or row.get("postMarketPrice") + prev = row.get("regularMarketPreviousClose") + change = (price - prev) if isinstance(price, (int, float)) and isinstance(prev, (int, float)) else None + change_pct = (change / prev * 100) if change is not None and prev else None + return Quote( + symbol=normalized, + price=float(price) if isinstance(price, (int, float)) else None, + currency=row.get("currency"), + change=change, + change_pct=change_pct, + source=self.name, + raw=row, + ) + + return await self._to_thread(fetch) + + async def profile(self, symbol: str) -> Profile: + def fetch() -> Profile: + normalized = symbol.strip().upper() + ticker = self._ticker(normalized) + profiles = ticker.asset_profile + row = profiles.get(normalized) if isinstance(profiles, dict) else None + if not isinstance(row, dict): + raise ProviderError("missing asset_profile payload") + return Profile( + symbol=normalized, + name=row.get("longName") or row.get("shortName"), + description=row.get("longBusinessSummary"), + sector=row.get("sector"), + industry=row.get("industry"), + country=row.get("country"), + exchange=row.get("exchange"), + website=row.get("website"), + employees=row.get("fullTimeEmployees"), + source=self.name, + raw=row, + ) + + return await self._to_thread(fetch) diff --git a/atlas-terminal/server/core/providers/yfinance.py b/atlas-terminal/server/core/providers/yfinance.py new file mode 100644 index 0000000..4ab0533 --- /dev/null +++ b/atlas-terminal/server/core/providers/yfinance.py @@ -0,0 +1,103 @@ +"""yfinance provider for the ATLAS Data Gateway.""" + +from __future__ import annotations + +from datetime import date +from typing import Any + +from server.core.data_gateway import Fundamentals, OHLCV, OHLCVBar, Profile, Quote +from server.core.providers.base import BaseProvider, ProviderError + + +class YFinanceProvider(BaseProvider): + name = "yfinance" + + def _ticker(self, symbol: str) -> Any: + try: + import yfinance as yf + except Exception as exc: # pragma: no cover - dependency is expected in app runtime + raise ProviderError(f"yfinance import failed: {exc}") from exc + return yf.Ticker(symbol.strip().upper()) + + async def quote(self, symbol: str) -> Quote: + def fetch() -> Quote: + ticker = self._ticker(symbol) + info = ticker.info or {} + price = info.get("regularMarketPrice") or info.get("currentPrice") or info.get("previousClose") + prev = info.get("regularMarketPreviousClose") or info.get("previousClose") + change = (price - prev) if isinstance(price, (int, float)) and isinstance(prev, (int, float)) else None + change_pct = (change / prev * 100) if change is not None and prev else None + return Quote( + symbol=symbol.upper(), + price=float(price) if isinstance(price, (int, float)) else None, + currency=info.get("currency"), + change=change, + change_pct=change_pct, + source=self.name, + raw=info, + ) + + return await self._to_thread(fetch) + + async def profile(self, symbol: str) -> Profile: + def fetch() -> Profile: + info = self._ticker(symbol).info or {} + return Profile( + symbol=symbol.upper(), + name=info.get("longName") or info.get("shortName"), + description=info.get("longBusinessSummary"), + sector=info.get("sector"), + industry=info.get("industry"), + country=info.get("country"), + exchange=info.get("exchange"), + currency=info.get("currency"), + website=info.get("website"), + employees=info.get("fullTimeEmployees"), + market_cap=info.get("marketCap"), + source=self.name, + raw=info, + ) + + return await self._to_thread(fetch) + + async def fundamentals(self, symbol: str, period: str = "annual") -> Fundamentals: + def fetch() -> Fundamentals: + ticker = self._ticker(symbol) + info = ticker.info or {} + return Fundamentals( + symbol=symbol.upper(), + period=period, + revenue=info.get("totalRevenue"), + gross_profit=info.get("grossProfits"), + operating_income=info.get("operatingMargins"), + net_income=info.get("netIncomeToCommon"), + ebitda=info.get("ebitda"), + free_cash_flow=info.get("freeCashflow"), + total_debt=info.get("totalDebt"), + cash=info.get("totalCash"), + shares=info.get("sharesOutstanding"), + source=self.name, + raw=info, + ) + + return await self._to_thread(fetch) + + async def history(self, symbol: str, range: str = "1y") -> OHLCV: + def fetch() -> OHLCV: + hist = self._ticker(symbol).history(period=range) + bars: list[OHLCVBar] = [] + for idx, row in hist.iterrows(): + idx_date = idx.date() if hasattr(idx, "date") else date.fromisoformat(str(idx)[:10]) + bars.append( + OHLCVBar( + date=idx_date, + open=float(row["Open"]) if "Open" in row else None, + high=float(row["High"]) if "High" in row else None, + low=float(row["Low"]) if "Low" in row else None, + close=float(row["Close"]) if "Close" in row else None, + volume=float(row["Volume"]) if "Volume" in row else None, + ) + ) + return OHLCV(symbol=symbol.upper(), range=range, bars=bars, source=self.name) + + return await self._to_thread(fetch) diff --git a/atlas-terminal/server/main.py b/atlas-terminal/server/main.py index a5f476f..7db5f62 100644 --- a/atlas-terminal/server/main.py +++ b/atlas-terminal/server/main.py @@ -81,7 +81,7 @@ app.add_middleware( ) # --- Mount routers --- -from server.routers import edgar, analysis, valuation, market_data, news, crypto, fx, portfolio, technical, financials, estimates, earnings, insider, screener, markets, fmp, macro, dart, edinet, research, chat # noqa: E402 +from server.routers import edgar, analysis, valuation, market_data, news, crypto, fx, portfolio, technical, financials, estimates, earnings, insider, screener, markets, fmp, macro, dart, edinet, research, chat, copilot # noqa: E402 app.include_router(edgar.router, prefix="/api/edgar", tags=["SEC EDGAR"]) app.include_router(analysis.router, prefix="/api/analysis", tags=["AI Analysis"]) @@ -104,6 +104,7 @@ app.include_router(dart.router, prefix="/api/dart", tags=["DART"]) app.include_router(edinet.router, prefix="/api/edinet", tags=["EDINET"]) app.include_router(research.router, prefix="/api/research", tags=["Research"]) app.include_router(chat.router, prefix="/api/chat", tags=["Chat"]) +app.include_router(copilot.router, prefix="/api/copilot", tags=["Copilot"]) @app.get("/health") diff --git a/atlas-terminal/server/models/schemas.py b/atlas-terminal/server/models/schemas.py index faeaca7..93fcf4b 100644 --- a/atlas-terminal/server/models/schemas.py +++ b/atlas-terminal/server/models/schemas.py @@ -288,6 +288,8 @@ class PortfolioPosition(BaseModel): exchange: str = "" source: str = "manual" current_price: Optional[float] = None + stock_currency: str = "" + yf_ticker: str = "" market_value: Optional[float] = None pnl: Optional[float] = None pnl_pct: Optional[float] = None diff --git a/atlas-terminal/server/routers/copilot.py b/atlas-terminal/server/routers/copilot.py new file mode 100644 index 0000000..42dc4d8 --- /dev/null +++ b/atlas-terminal/server/routers/copilot.py @@ -0,0 +1,66 @@ +"""Copilot chat router with terminal context injection.""" + +from __future__ import annotations + +import json +from typing import Any, Literal + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from server.routers.analysis import _call_gemini, _get_financial_context + +router = APIRouter() + + +class CopilotContext(BaseModel): + activeSymbol: str | None = None + activePage: str = "equity" + recentSymbols: list[str] = Field(default_factory=list) + currency: str = "USD" + theme: str = "bloomberg" + watchlist: list[str] = Field(default_factory=list) + + +class CopilotMessage(BaseModel): + role: Literal["user", "assistant", "system"] + content: str + + +class CopilotChatRequest(BaseModel): + message: str + context: CopilotContext = Field(default_factory=CopilotContext) + history: list[CopilotMessage] = Field(default_factory=list) + api_key: str = "" + + +@router.post("/chat") +async def copilot_chat(req: CopilotChatRequest) -> dict[str, Any]: + api_key = req.api_key.strip() + if not api_key: + raise HTTPException(status_code=400, detail="Gemini API key is required") + + active_symbol = (req.context.activeSymbol or "").strip().upper() + context_json = json.dumps(req.context.model_dump(), ensure_ascii=False, indent=2) + financial_context = _get_financial_context(active_symbol) if active_symbol else "No active financial symbol." + history = "\n".join(f"{msg.role}: {msg.content}" for msg in req.history[-8:]) + prompt = f""" +You are ATLAS Copilot, a concise equity research assistant embedded in ATLAS Terminal. +Use the terminal context below to answer the user's question. Do not invent numbers. +If a requested figure is not in context, say what data would be needed. + +Terminal context: +{context_json} + +Financial context for active symbol: +{financial_context} + +Recent conversation: +{history or "No prior messages."} + +User question: +{req.message} +""".strip() + + text = await _call_gemini(api_key=api_key, prompt=prompt, max_tokens=2048, temperature=0.35) + return {"message": text, "context": req.context.model_dump()} diff --git a/atlas-terminal/server/routers/fx.py b/atlas-terminal/server/routers/fx.py index a25f91e..16db1ad 100644 --- a/atlas-terminal/server/routers/fx.py +++ b/atlas-terminal/server/routers/fx.py @@ -1,5 +1,8 @@ """FX router -- foreign exchange rates and historical data via yfinance.""" +import asyncio +import time +from threading import Lock from typing import Dict, List from fastapi import APIRouter, HTTPException @@ -7,6 +10,9 @@ from fastapi import APIRouter, HTTPException from server.models.schemas import FXRateResponse, FXHistoryResponse router = APIRouter() +_FX_RATE_CACHE_TTL_SECONDS = 300 +_FX_RATE_CACHE: dict[str, tuple[float, float]] = {} +_FX_RATE_CACHE_LOCK = Lock() # Major FX pairs tracked by default (Yahoo Finance format: XXXYYY=X) MAJOR_PAIRS = [ @@ -24,18 +30,31 @@ def _yf_fx_symbol(pair: str) -> str: def _fetch_fx_rate(pair: str) -> float | None: """Fetch the latest FX rate for a single pair via yfinance.""" try: + cache_key = pair.upper().replace("=X", "").replace("/", "") + now = time.monotonic() + with _FX_RATE_CACHE_LOCK: + cached = _FX_RATE_CACHE.get(cache_key) + if cached and now - cached[0] < _FX_RATE_CACHE_TTL_SECONDS: + return cached[1] + import yfinance as yf symbol = _yf_fx_symbol(pair) ticker = yf.Ticker(symbol) + rate = None fast = getattr(ticker, "fast_info", None) if fast: price = getattr(fast, "last_price", None) if price and float(price) > 0: - return float(price) - hist = ticker.history(period="1d") - if hist is not None and not hist.empty: - return float(hist["Close"].iloc[-1]) + rate = float(price) + if rate is None: + hist = ticker.history(period="1d") + if hist is not None and not hist.empty: + rate = float(hist["Close"].iloc[-1]) + if rate is not None: + with _FX_RATE_CACHE_LOCK: + _FX_RATE_CACHE[cache_key] = (now, rate) + return rate except Exception: pass return None @@ -47,39 +66,27 @@ def _fetch_fx_rate(pair: str) -> float | None: summary="FX conversion matrix for major currencies", ) async def fx_rates(): - """Return conversion matrix (USD/GBP/EUR/JPY/KRW).""" + """Return conversion matrix for portfolio display currencies.""" try: - gbp_usd = _fetch_fx_rate("GBPUSD") or 1.27 - eur_usd = _fetch_fx_rate("EURUSD") or 1.08 - usd_jpy = _fetch_fx_rate("USDJPY") or 149.5 - usd_krw = _fetch_fx_rate("USDKRW") or 1370.0 - + gbp_usd, eur_usd, usd_jpy, usd_krw, usd_dkk = await asyncio.gather( + asyncio.to_thread(_fetch_fx_rate, "GBPUSD"), + asyncio.to_thread(_fetch_fx_rate, "EURUSD"), + asyncio.to_thread(_fetch_fx_rate, "USDJPY"), + asyncio.to_thread(_fetch_fx_rate, "USDKRW"), + asyncio.to_thread(_fetch_fx_rate, "USDDKK"), + ) + usd_value = { + "USD": 1.0, + "GBP": gbp_usd or 1.27, + "EUR": eur_usd or 1.08, + "JPY": 1 / (usd_jpy or 149.5), + "KRW": 1 / (usd_krw or 1370.0), + "DKK": 1 / (usd_dkk or 6.86), + } rates = { - "USD_USD": 1.0, - "USD_GBP": 1 / gbp_usd, - "USD_EUR": 1 / eur_usd, - "USD_JPY": usd_jpy, - "USD_KRW": usd_krw, - "GBP_USD": gbp_usd, - "GBP_GBP": 1.0, - "GBP_EUR": gbp_usd / eur_usd, - "GBP_JPY": gbp_usd * usd_jpy, - "GBP_KRW": gbp_usd * usd_krw, - "EUR_USD": eur_usd, - "EUR_GBP": eur_usd / gbp_usd, - "EUR_EUR": 1.0, - "EUR_JPY": eur_usd * usd_jpy, - "EUR_KRW": eur_usd * usd_krw, - "JPY_USD": 1 / usd_jpy, - "JPY_GBP": 1 / (gbp_usd * usd_jpy), - "JPY_EUR": 1 / (eur_usd * usd_jpy), - "JPY_JPY": 1.0, - "JPY_KRW": usd_krw / usd_jpy, - "KRW_USD": 1 / usd_krw, - "KRW_GBP": 1 / (gbp_usd * usd_krw), - "KRW_EUR": 1 / (eur_usd * usd_krw), - "KRW_JPY": usd_jpy / usd_krw, - "KRW_KRW": 1.0, + f"{src}_{dst}": src_usd / dst_usd + for src, src_usd in usd_value.items() + for dst, dst_usd in usd_value.items() } return FXRateResponse(pair="MATRIX", rates=rates) except Exception as exc: diff --git a/atlas-terminal/server/routers/market_data.py b/atlas-terminal/server/routers/market_data.py index 97baefa..6a8e0ee 100644 --- a/atlas-terminal/server/routers/market_data.py +++ b/atlas-terminal/server/routers/market_data.py @@ -1,9 +1,12 @@ """Market Data router -- sector info, financial trends, comps, health metrics.""" +import asyncio import logging from typing import Any, Dict, List from fastapi import APIRouter, Query +from server.core import flags as core_flags +from server.core.factory import get_data_gateway from server.utils.ticker_utils import AssetType, detect_asset_type router = APIRouter() @@ -103,6 +106,17 @@ async def market_overview_by_ticker(ticker: str): return {"error": str(e), "asset_type": AssetType.EQUITY.value, "data": None} +@router.get("/asset-type/{ticker}", summary="Lightweight asset type detection") +async def asset_type_by_ticker(ticker: str): + """Return only the detected asset type without building the full overview payload.""" + try: + asset_type = await asyncio.to_thread(detect_asset_type, ticker) + return {"ticker": ticker.upper(), "asset_type": asset_type.value} + except Exception as e: + logger.exception("asset-type/%s failed", ticker) + return {"error": str(e), "ticker": ticker.upper(), "asset_type": AssetType.EQUITY.value} + + @router.get("/etf/{ticker}/holdings", summary="ETF top holdings") async def etf_holdings(ticker: str): try: @@ -479,6 +493,18 @@ async def piotroski_score(ticker: str): @router.get("/quote/{ticker}", summary="Quick quote: price and session change %") async def quick_quote(ticker: str): """Used for news headline ticker mentions (day session move).""" + if core_flags.new_data_gateway_enabled(): + try: + quote = await get_data_gateway().quote(ticker) + return { + "ticker": ticker.upper(), + "current_price": quote.price, + "change_pct": round(quote.change_pct, 2) if quote.change_pct is not None else None, + } + except Exception: + logger.exception("quote/%s gateway failed", ticker) + return {"ticker": ticker.upper(), "current_price": None, "change_pct": None} + try: import yfinance as yf diff --git a/atlas-terminal/server/routers/portfolio.py b/atlas-terminal/server/routers/portfolio.py index 0d00575..808bed9 100644 --- a/atlas-terminal/server/routers/portfolio.py +++ b/atlas-terminal/server/routers/portfolio.py @@ -1,9 +1,12 @@ """Portfolio router -- position management, OCR screenshot upload, summary.""" +import asyncio import json import os +import time import uuid from pathlib import Path +from threading import Lock from typing import List import logging @@ -21,6 +24,9 @@ logger = logging.getLogger(__name__) # Simple file-based persistence (production would use Supabase / Postgres) _PORTFOLIO_FILE = Path(__file__).resolve().parent.parent.parent / "data" / "portfolio.json" +_QUOTE_CACHE_TTL_SECONDS = 60 +_QUOTE_CACHE: dict[str, tuple[float, dict]] = {} +_QUOTE_CACHE_LOCK = Lock() class PositionUpdateRequest(BaseModel): @@ -54,23 +60,45 @@ def _save_positions(positions: List[dict]) -> None: json.dump(positions, f, ensure_ascii=False, indent=2) -def _get_current_price(ticker: str) -> float | None: - """Fetch the latest market price for *ticker*.""" +def _get_current_quote(ticker: str, exchange: str = "") -> dict: + """Fetch the latest market quote for *ticker* with a short in-process TTL cache.""" try: import yfinance as yf + from server.services.exchange_resolver import resolve_exchange_option - t = yf.Ticker(ticker.upper()) + option = resolve_exchange_option(ticker, exchange or None) or {} + yf_ticker = str(option.get("yf_ticker") or ticker).upper() + now = time.monotonic() + with _QUOTE_CACHE_LOCK: + cached = _QUOTE_CACHE.get(yf_ticker) + if cached and now - cached[0] < _QUOTE_CACHE_TTL_SECONDS: + return cached[1] + + t = yf.Ticker(yf_ticker) + price = None fast = getattr(t, "fast_info", None) if fast: price = getattr(fast, "last_price", None) - if price and float(price) > 0: - return float(price) - hist = t.history(period="1d") - if hist is not None and not hist.empty: - return float(hist["Close"].iloc[-1]) + if not price or float(price) <= 0: + hist = t.history(period="1d") + if hist is not None and not hist.empty: + price = float(hist["Close"].iloc[-1]) + + quote = { + "price": float(price) if price and float(price) > 0 else None, + "currency": str(option.get("currency") or "").upper(), + "yf_ticker": yf_ticker, + } + with _QUOTE_CACHE_LOCK: + _QUOTE_CACHE[yf_ticker] = (now, quote) + return quote except Exception: - pass - return None + return {"price": None, "currency": "", "yf_ticker": ticker.upper()} + + +async def _get_current_quote_async(ticker: str, exchange: str, semaphore: asyncio.Semaphore) -> dict: + async with semaphore: + return await asyncio.to_thread(_get_current_quote, ticker, exchange) # --------------------------------------------------------------------------- @@ -288,15 +316,24 @@ async def portfolio_summary(): enriched: List[PortfolioPosition] = [] total_value = 0.0 total_cost = 0.0 + quote_semaphore = asyncio.Semaphore(8) + quotes = await asyncio.gather( + *[ + _get_current_quote_async(str(p.get("ticker", "")), str(p.get("exchange", "")), quote_semaphore) + for p in positions + ], + return_exceptions=True, + ) - for p in positions: + for p, quote_result in zip(positions, quotes): + quote = quote_result if isinstance(quote_result, dict) else {"price": None, "currency": "", "yf_ticker": p.get("ticker", "")} ticker = p.get("ticker", "") quantity = float(p.get("quantity", 0)) avg_price = float(p.get("avg_price", 0)) cost = quantity * avg_price total_cost += cost - current_price = _get_current_price(ticker) + current_price = quote.get("price") market_value = (quantity * current_price) if current_price else None pnl = (market_value - cost) if market_value is not None else None pnl_pct = (pnl / cost * 100) if (pnl is not None and cost > 0) else None @@ -314,6 +351,8 @@ async def portfolio_summary(): exchange=p.get("exchange", ""), source=p.get("source", "manual"), current_price=current_price, + stock_currency=quote.get("currency") or p.get("currency", "USD"), + yf_ticker=quote.get("yf_ticker") or ticker, market_value=market_value, pnl=pnl, pnl_pct=round(pnl_pct, 2) if pnl_pct is not None else None, diff --git a/atlas-terminal/server/services/dcf_engine.py b/atlas-terminal/server/services/dcf_engine.py index 65b514b..e7fdd40 100644 --- a/atlas-terminal/server/services/dcf_engine.py +++ b/atlas-terminal/server/services/dcf_engine.py @@ -185,10 +185,10 @@ def _damodaran_wacc_for_sector(sector: str) -> float: if not sector: return 8.0 s = (sector or "").lower() - if "software" in s or "technology" in s or "internet" in s: - return DAMODARAN_WACC.get("Software", 8.5) if "hardware" in s or "semiconductor" in s: return DAMODARAN_WACC.get("Hardware", 9.0) + if "software" in s or "internet" in s or s in {"technology", "information technology"}: + return DAMODARAN_WACC.get("Software", 8.5) if "retail" in s or "consumer" in s or "cyclical" in s: return DAMODARAN_WACC.get("Retail", 7.5) if "financial" in s or "bank" in s or "insurance" in s: diff --git a/atlas-terminal/server/services/exchange_resolver.py b/atlas-terminal/server/services/exchange_resolver.py index 68cd7d9..2693da0 100644 --- a/atlas-terminal/server/services/exchange_resolver.py +++ b/atlas-terminal/server/services/exchange_resolver.py @@ -9,11 +9,13 @@ MULTI_EXCHANGE_TICKERS = { {"exchange": "OTC (US)", "yf_ticker": "SSNLF", "currency": "USD"}, ], "NOV": [ - {"exchange": "NYSE", "yf_ticker": "NVO", "currency": "USD", "default": True}, + {"exchange": "Frankfurt / Trading 212", "yf_ticker": "NOV.F", "currency": "EUR", "default": True}, + {"exchange": "NYSE", "yf_ticker": "NVO", "currency": "USD"}, {"exchange": "Copenhagen", "yf_ticker": "NOVO-B.CO", "currency": "DKK"}, ], "NVO": [ {"exchange": "NYSE", "yf_ticker": "NVO", "currency": "USD", "default": True}, + {"exchange": "Frankfurt / Trading 212", "yf_ticker": "NOV.F", "currency": "EUR"}, {"exchange": "Copenhagen", "yf_ticker": "NOVO-B.CO", "currency": "DKK"}, ], } @@ -35,17 +37,36 @@ def get_exchange_options(ticker: str) -> list[dict]: return MULTI_EXCHANGE_TICKERS.get((ticker or "").upper(), []) -def resolve_ticker_with_exchange(ticker: str, selected_exchange: str | None = None) -> str: +def resolve_exchange_option( + ticker: str, + selected_exchange: str | None = None, + preferred_currency: str | None = None, +) -> dict | None: t = (ticker or "").upper().strip() options = get_exchange_options(t) if not options: - return T212_TICKER_MAP.get(t, t) + mapped = T212_TICKER_MAP.get(t, t) + return {"exchange": "", "yf_ticker": mapped, "currency": ""} if selected_exchange: for opt in options: if opt.get("exchange") == selected_exchange: - return opt.get("yf_ticker", t) + return opt + pref = (preferred_currency or "").upper().strip() + if pref: + for opt in options: + if (opt.get("currency") or "").upper() == pref: + return opt for opt in options: if opt.get("default"): - return opt.get("yf_ticker", t) - return options[0].get("yf_ticker", t) + return opt + return options[0] + +def resolve_ticker_with_exchange( + ticker: str, + selected_exchange: str | None = None, + preferred_currency: str | None = None, +) -> str: + t = (ticker or "").upper().strip() + option = resolve_exchange_option(t, selected_exchange, preferred_currency) + return (option or {}).get("yf_ticker", T212_TICKER_MAP.get(t, t)) diff --git a/atlas-terminal/server/services/screenshot_ocr.py b/atlas-terminal/server/services/screenshot_ocr.py index 51a9b64..b5fcbce 100644 --- a/atlas-terminal/server/services/screenshot_ocr.py +++ b/atlas-terminal/server/services/screenshot_ocr.py @@ -8,7 +8,7 @@ import re from typing import Any, Optional import yfinance as yf -from server.services.exchange_resolver import resolve_ticker_with_exchange +from server.services.exchange_resolver import resolve_exchange_option, resolve_ticker_with_exchange SCREENSHOT_OCR_PROMPT = """ Analyze this screenshot of a stock trading app portfolio (Trading 212, IBKR, Webull, etc). @@ -99,6 +99,12 @@ def _get_fx_rate(from_currency: str, to_currency: str) -> float: return fallback.get((f, t), 1.0) +def _preferred_stock_currency(pos: dict) -> str | None: + """Use stock-level currency hints only; account/display value currency can differ.""" + avg_currency = _norm_currency(pos.get("avg_price_currency"), "") + return avg_currency or None + + def reverse_engineer_positions(ocr_result: dict, exchange_overrides: dict[str, str] | None = None) -> list[dict]: account_currency = _norm_currency(ocr_result.get("account_currency"), "USD") out: list[dict] = [] @@ -107,7 +113,17 @@ def reverse_engineer_positions(ocr_result: dict, exchange_overrides: dict[str, s if not ticker: continue selected_exchange = (exchange_overrides or {}).get(ticker) - yf_ticker = resolve_ticker_with_exchange(ticker, selected_exchange) + exchange_option = resolve_exchange_option( + ticker, + selected_exchange, + preferred_currency=_preferred_stock_currency(pos) if not selected_exchange else None, + ) + resolved_exchange = (exchange_option or {}).get("exchange") or selected_exchange or "" + yf_ticker = resolve_ticker_with_exchange( + ticker, + selected_exchange, + preferred_currency=_preferred_stock_currency(pos) if not selected_exchange else None, + ) mkt = _get_realtime_price(yf_ticker) if not mkt: out.append({ @@ -124,6 +140,7 @@ def reverse_engineer_positions(ocr_result: dict, exchange_overrides: dict[str, s "confidence": "low", "method": "ocr_only", "yf_ticker": yf_ticker, + "exchange": resolved_exchange, }) continue @@ -228,6 +245,7 @@ def reverse_engineer_positions(ocr_result: dict, exchange_overrides: dict[str, s "method": method, "avg_method": avg_method, "yf_ticker": yf_ticker, + "exchange": resolved_exchange, }) return out diff --git a/atlas-terminal/tests/test_data_gateway.py b/atlas-terminal/tests/test_data_gateway.py new file mode 100644 index 0000000..7c5dfee --- /dev/null +++ b/atlas-terminal/tests/test_data_gateway.py @@ -0,0 +1,84 @@ +"""Unit tests for the v2 Data Gateway foundation.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from server.core.cache import CachedGateway +from server.core.chained_gateway import ChainedGateway +from server.core.data_gateway import Quote +from server.core.provider_metrics import provider_metrics +from server.core.providers.base import BaseProvider, DataUnavailable, ProviderError + + +class FailingProvider(BaseProvider): + name = "failing" + + async def quote(self, symbol: str) -> Quote: + raise ProviderError("boom") + + +class CountingProvider(BaseProvider): + name = "counting" + + def __init__(self) -> None: + self.calls = 0 + + async def quote(self, symbol: str) -> Quote: + self.calls += 1 + return Quote(symbol=symbol.upper(), price=123.45, currency="USD", source=self.name) + + +class ExplodingProvider(BaseProvider): + name = "exploding" + + async def quote(self, symbol: str) -> Quote: + raise RuntimeError("sdk timeout") + + +def test_chained_gateway_falls_back_to_next_provider() -> None: + provider_metrics.clear() + gateway = ChainedGateway([FailingProvider(), CountingProvider()]) + + quote = asyncio.run(gateway.quote("aapl")) + + assert quote.symbol == "AAPL" + assert quote.price == 123.45 + assert quote.source == "counting" + rows = provider_metrics.snapshot() + assert {(row.provider, row.method, row.attempts, row.successes, row.failures) for row in rows} == { + ("counting", "quote", 1, 1, 0), + ("failing", "quote", 1, 0, 1), + } + + +def test_chained_gateway_raises_after_all_providers_fail() -> None: + gateway = ChainedGateway([FailingProvider()]) + + with pytest.raises(DataUnavailable): + asyncio.run(gateway.quote("AAPL")) + + +def test_chained_gateway_falls_back_after_unexpected_provider_exception() -> None: + provider_metrics.clear() + gateway = ChainedGateway([ExplodingProvider(), CountingProvider()]) + + quote = asyncio.run(gateway.quote("AAPL")) + + assert quote.price == 123.45 + rows = provider_metrics.snapshot() + assert any(row.provider == "exploding" and row.failures == 1 for row in rows) + + +def test_cached_gateway_reuses_quote_result() -> None: + provider_metrics.clear() + provider = CountingProvider() + gateway = CachedGateway(ChainedGateway([provider])) + + first = asyncio.run(gateway.quote("AAPL")) + second = asyncio.run(gateway.quote("aapl")) + + assert first is second + assert provider.calls == 1 diff --git a/atlas-terminal/tests/test_smoke.py b/atlas-terminal/tests/test_smoke.py new file mode 100644 index 0000000..5d94ec2 --- /dev/null +++ b/atlas-terminal/tests/test_smoke.py @@ -0,0 +1,97 @@ +"""Smoke tests that keep the FastAPI shell safe during refactors.""" + +from fastapi.testclient import TestClient + +from server.core.providers.base import DataUnavailable +from server.core.data_gateway import Quote +from server.main import app + + +EXPECTED_PREFIXES = [ + "/api/analysis", + "/api/chat", + "/api/crypto", + "/api/copilot", + "/api/dart", + "/api/earnings", + "/api/edgar", + "/api/edinet", + "/api/estimates", + "/api/financials", + "/api/fmp", + "/api/fx", + "/api/insider", + "/api/macro", + "/api/market", + "/api/markets", + "/api/news", + "/api/portfolio", + "/api/research", + "/api/screener", + "/api/technical", + "/api/valuation", +] + + +def test_health_endpoint_returns_ok() -> None: + with TestClient(app) as client: + response = client.get("/api/health") + + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + +def test_openapi_contains_all_router_prefixes() -> None: + with TestClient(app) as client: + response = client.get("/openapi.json") + + assert response.status_code == 200 + paths = response.json()["paths"].keys() + for prefix in EXPECTED_PREFIXES: + assert any(path.startswith(prefix) for path in paths), prefix + + +def test_lightweight_asset_type_endpoint(monkeypatch) -> None: + from server.routers import market_data + + monkeypatch.setattr(market_data, "detect_asset_type", lambda ticker: market_data.AssetType.EQUITY) + + with TestClient(app) as client: + response = client.get("/api/market/asset-type/AAPL") + + assert response.status_code == 200 + assert response.json() == {"ticker": "AAPL", "asset_type": "equity"} + + +def test_quote_endpoint_can_use_gateway_flag(monkeypatch) -> None: + from server.routers import market_data + + class FakeGateway: + async def quote(self, ticker: str) -> Quote: + return Quote(symbol=ticker.upper(), price=123.45, change_pct=1.234, source="fake") + + monkeypatch.setattr(market_data.core_flags, "new_data_gateway_enabled", lambda: True) + monkeypatch.setattr(market_data, "get_data_gateway", lambda: FakeGateway()) + + with TestClient(app) as client: + response = client.get("/api/market/quote/AAPL") + + assert response.status_code == 200 + assert response.json() == {"ticker": "AAPL", "current_price": 123.45, "change_pct": 1.23} + + +def test_quote_endpoint_gateway_failure_degrades(monkeypatch) -> None: + from server.routers import market_data + + class FailingGateway: + async def quote(self, ticker: str) -> Quote: + raise DataUnavailable(ticker, "quote") + + monkeypatch.setattr(market_data.core_flags, "new_data_gateway_enabled", lambda: True) + monkeypatch.setattr(market_data, "get_data_gateway", lambda: FailingGateway()) + + with TestClient(app) as client: + response = client.get("/api/market/quote/AAPL") + + assert response.status_code == 200 + assert response.json() == {"ticker": "AAPL", "current_price": None, "change_pct": None}