phase 2: terminal state and gateway foundation

This commit is contained in:
shawnkim1997
2026-04-21 20:23:48 +01:00
parent 25b2ad7e15
commit 1527e7d754
52 changed files with 2408 additions and 163 deletions
+49
View File
@@ -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
+26
View File
@@ -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
+28 -1
View File
@@ -1,5 +1,7 @@
# ATLAS Terminal
[![CI](https://github.com/shawnkim1997/10-K-summariser-project/actions/workflows/ci.yml/badge.svg)](https://github.com/shawnkim1997/10-K-summariser-project/actions/workflows/ci.yml)
Institutional-style equity research terminal built with Next.js 14 and FastAPI.
ATLAS Terminal brings market overview, quant research, valuation, technical analysis, macro monitoring, filings workflows, and printable institutional reports into one desktop-first interface.
@@ -49,7 +51,8 @@ You can also click the screenshot below to open the recorded walkthrough:
## Stack
- Frontend: Next.js 14, React 18, TypeScript, Tailwind CSS, Recharts, Lightweight Charts
- Backend: FastAPI, Python 3.12+, Pydantic, yfinance, pandas, scipy
- Frontend state: Zustand persistent terminal store, shared API hook, Playwright smoke tests
- Backend: FastAPI, Python 3.12+, Pydantic, yfinance, yahooquery, FMP gateway scaffold, pandas, scipy
- Data: SEC, DART, EDINET, FRED, OECD, DBnomics, Yahoo Finance
- AI: Gemini for qualitative analysis only
- Storage: SQLite by default
@@ -82,6 +85,16 @@ npm install
npm run dev
```
### Verification
```bash
pytest tests -q
cd apps/web
npm run typecheck
npm run build
npm run e2e
```
### Local URLs
- Frontend: [http://localhost:3000](http://localhost:3000)
@@ -90,11 +103,25 @@ npm run dev
## Recent Work
- v2 refactor foundation: baseline measurements in `docs/baseline-2026-04.md`, CI workflow, pytest smoke tests, and Playwright route smoke tests
- Data Gateway scaffold: typed `DataGateway` contract, chained providers, TTL cache wrapper, provider metrics, and a flag-gated `/api/market/quote/{ticker}` migration path via `ATLAS_FLAG_GATEWAY=true`
- Central terminal state: Zustand-backed `useTerminal` store for active symbol, page context, recent symbols, watchlist, currency, theme, layouts, and Copilot context
- Copilot context injection: right rail chat now sends terminal context to `/api/copilot/chat` on every turn
- Keyboard workflow: `Cmd/Ctrl+K` and `G` focus ticker search, `/` focuses Copilot, `W` adds the active symbol to watchlist, and `P/M/N` navigate Portfolio/Macro/News
- Smarter ticker search: company-name and Korean aliases now resolve suggestions such as Berkshire Hathaway, SK hynix, Samsung Electronics, Toyota, Novo Nordisk, and common ETFs/commodities
- Portfolio and FX reliability: exchange-aware Novo Nordisk EUR handling, faster FX/portfolio repeat loads, and cleaner local artifact ignore rules
- Morgan Stanley-inspired redesign across the shell, overview, research, valuation, technical, macro, settings, and report flows
- Shared chart palette and UI primitives for a more consistent desktop terminal experience
- Research dashboard performance fixes for faster repeat loads and less blocking on page open
- Improved macro failure states, report messaging, tooltip formatting, and chart legibility
## Refactor Roadmap
- Phase 0: Foundation safety net, baseline docs, CI, backend smoke tests, frontend e2e smoke tests
- Phase 1: Data Gateway migration behind `ATLAS_FLAG_GATEWAY`, starting with low-risk quote data before wider overview/profile routes
- Phase 2: Global terminal state through Zustand, Copilot context, and keyboard-first terminal navigation
- Next phases: encrypted credential vault, peer comparison, earnings-call delta analysis, and smaller institutional feature gaps
## Why This Project
ATLAS Terminal started as an attempt to build a personal Bloomberg-lite for retail investing workflows: high information density, clean narrative structure, and a hard separation between AI-generated language and deterministic financial computation.
@@ -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();
}
+13
View File
@@ -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();
});
+13
View File
@@ -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();
});
@@ -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();
});
@@ -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();
});
+17 -12
View File
@@ -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*",
},
];
},
};
}
+9 -3
View File
@@ -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",
@@ -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"] },
},
],
});
@@ -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 (
<>
<TickerBar />
@@ -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<Array<{ role: string; content: string }>>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(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() {
<div className="border-t border-border bg-surface-raised p-3">
<div className="flex gap-2 rounded-md border border-border bg-surface-sunken px-3.5 py-2.5">
<input
id="atlas-copilot-input"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
@@ -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 }) => (
<div className="px-2 py-1 text-xs">
@@ -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<HTMLInputElement>) {
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() {
</div>
<Sparkles className="h-4 w-4 text-brand-gold" />
</div>
<div className="flex items-center gap-2 rounded-md border border-border bg-surface-sunken px-3 py-2">
<div
role="combobox"
aria-expanded={showSuggestions}
aria-controls="ticker-suggestions"
aria-haspopup="listbox"
className="flex items-center gap-2 rounded-md border border-border bg-surface-sunken px-3 py-2"
>
<Search className="h-4 w-4 text-text-muted" />
<input
id="atlas-ticker-search"
value={input}
onChange={(e) => 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"
/>
</div>
{showSuggestions && (
<div id="ticker-suggestions" role="listbox" className="mt-2 overflow-hidden rounded-md border border-border bg-surface-raised shadow-card">
{suggestions.map((suggestion, index) => {
const active = index === highlightedIndex;
return (
<button
key={`${suggestion.ticker}-${suggestion.exchange}`}
type="button"
role="option"
aria-selected={active}
onMouseDown={(event) => {
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"
}`}
>
<div className="flex items-center justify-between gap-2">
<span className={`font-mono text-sm font-bold ${active ? "text-brand-gold" : "text-brand-navy"}`}>{suggestion.ticker}</span>
<span className={`rounded border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.08em] ${
active ? "border-white/20 text-white/70" : "border-border text-text-muted"
}`}>
{suggestion.exchange}
</span>
</div>
<div className={`mt-0.5 truncate text-xs ${active ? "text-white/80" : "text-text-secondary"}`}>{suggestion.name}</div>
</button>
);
})}
</div>
)}
{input.trim().length > 0 && suggestions.length === 0 && (
<div className="mt-2 rounded-md border border-border bg-surface-sunken px-3 py-2 text-xs text-text-muted">
No match yet. Press Enter to use <span className="font-mono text-brand-navy">{input.trim().toUpperCase()}</span>.
</div>
)}
<div className="mt-3 flex items-center justify-between rounded-md bg-brand-navy px-3 py-2 text-white">
<span className="text-xs uppercase tracking-[0.12em] text-white/70">Ticker</span>
<span className="font-mono text-sm font-bold text-brand-gold">{ticker}</span>
@@ -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<IndexData[]>(
INDICES.map((i) => ({ ...i, price: "—", change: "—", positive: true }))
);
const indices = useApi<IndexData[]>("/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 (
<header className="fixed left-0 right-0 top-0 z-50 flex h-[56px] items-center gap-4 border-b border-border bg-surface-raised px-5 shadow-card">
@@ -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;
@@ -1,15 +1,238 @@
const TICKER_ALIASES: Record<string, string> = {
// 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<string, string> = {
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<string, string> = {
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<string, string> = {
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();
}
+29 -9
View File
@@ -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<string, InFlightEntry>();
const responseCache = new Map<string, CacheEntry>();
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<string, string>;
cacheTtlMs?: number;
}
export interface UseApiResult<T> {
@@ -114,7 +119,7 @@ export function useApi<T = unknown>(
url: string | null,
opts: UseApiOptions = {},
): UseApiResult<T> {
const { enabled = true, method = "GET", body, headers } = opts;
const { enabled = true, method = "GET", body, headers, cacheTtlMs } = opts;
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState<boolean>(Boolean(url) && enabled !== false);
@@ -130,6 +135,7 @@ export function useApi<T = unknown>(
}, []);
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<T = unknown>(
}
const controller = new AbortController();
setLoading(true);
setError(null);
const init: RequestInit = {
method,
headers: {
@@ -149,6 +152,19 @@ export function useApi<T = unknown>(
},
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<T = unknown>(
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<T = unknown>(
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 };
}
@@ -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 };
}
@@ -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<Position[]>([]);
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<Record<string, number>>({});
const [exchangeSelections, setExchangeSelections] = useState<Record<string, string>>({});
const [exchangeOptions, setExchangeOptions] = useState<Record<string, { exchange: string; yf_ticker: string; currency: string; default?: boolean }[]>>({});
const [exchangeOptions, setExchangeOptions] = useState<Record<string, ExchangeOption[]>>({});
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 ? (
<select
className="bg-bg-primary border border-border rounded px-2 py-1 text-xs"
value={exchangeSelections[rowKey] || ""}
value={exchangeSelections[rowKey] || p.exchange || ""}
onChange={(e) => 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)}</>
)}
</td>
<td className="px-4 py-2.5 font-mono text-text-primary">{formatCurrencyValue(convertAmount(price, srcValueCurrency, displayCurrency), displayCurrency)}</td>
<td className="px-4 py-2.5 font-mono text-text-primary">{formatCurrencyValue(value, displayCurrency)}</td>
<td className={`px-4 py-2.5 font-mono ${gl >= 0 ? "text-accent-green" : "text-accent-red"}`}>
{gl >= 0 ? "+" : ""}{formatCurrencyValue(Math.abs(gl), displayCurrency)}
{gl >= 0 ? "+" : "-"}{formatCurrencyValue(Math.abs(gl), displayCurrency)}
</td>
<td className={`px-4 py-2.5 font-mono ${glPct >= 0 ? "text-accent-green" : "text-accent-red"}`}>
{glPct >= 0 ? "+" : ""}{glPct.toFixed(1)}%
@@ -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; }
}
`}</style>
</>
@@ -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: () => <LoadingPulse label="Loading quant widgets…" />,
},
);
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<AssetTypeResp>(assetUrl, { cacheTtlMs: 5 * 60_000 });
const overview = useApi<OverviewResp>(overviewUrl);
const assetType = asset.data?.asset_type || "equity";
const dashUrl =
initialized && !asset.loading && assetType === "equity"
? `/api/research/dashboard/${encodeURIComponent(ticker)}`
: null;
const dashboard = useApi<ResearchDashboardPayload>(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 <LoadingPulse label="Loading research…" />;
@@ -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<HTMLElement>(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]);
}
@@ -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<string, GridLayout>;
}
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<TerminalState>()(
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);
},
},
),
);
+10
View File
@@ -0,0 +1,10 @@
declare module "tinykeys" {
export type TinyKeysHandler = (event: KeyboardEvent) => void;
export type TinyKeysKeyBindingMap = Record<string, TinyKeysHandler>;
export function tinykeys(
target: Window | Document | HTMLElement,
keyBindingMap: TinyKeysKeyBindingMap,
options?: { event?: string; capture?: boolean },
): () => void;
}
+18 -4
View File
@@ -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"
]
}
+65
View File
@@ -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.
+29
View File
@@ -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",
]
+87
View File
@@ -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()
@@ -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))
+134
View File
@@ -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]: ...
+31
View File
@@ -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(),
]
)
)
+15
View File
@@ -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")
@@ -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()
@@ -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",
]
@@ -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")
@@ -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")
@@ -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")
@@ -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)
@@ -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)
+2 -1
View File
@@ -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")
+2
View File
@@ -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
+66
View File
@@ -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()}
+42 -35
View File
@@ -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:
@@ -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
+51 -12
View File
@@ -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,
+2 -2
View File
@@ -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:
@@ -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))
@@ -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
+84
View File
@@ -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
+97
View File
@@ -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}