mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-13 10:28:05 +00:00
phase 2: terminal state and gateway foundation
This commit is contained in:
+49
@@ -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
|
||||
@@ -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
|
||||
@@ -1,5 +1,7 @@
|
||||
# ATLAS Terminal
|
||||
|
||||
[](https://github.com/shawnkim1997/10-K-summariser-project/actions/workflows/ci.yml)
|
||||
|
||||
Institutional-style equity research terminal built with Next.js 14 and FastAPI.
|
||||
|
||||
ATLAS Terminal brings market overview, quant research, valuation, technical analysis, macro monitoring, filings workflows, and printable institutional reports into one desktop-first interface.
|
||||
@@ -49,7 +51,8 @@ You can also click the screenshot below to open the recorded walkthrough:
|
||||
## Stack
|
||||
|
||||
- Frontend: Next.js 14, React 18, TypeScript, Tailwind CSS, Recharts, Lightweight Charts
|
||||
- Backend: FastAPI, Python 3.12+, Pydantic, yfinance, pandas, scipy
|
||||
- Frontend state: Zustand persistent terminal store, shared API hook, Playwright smoke tests
|
||||
- Backend: FastAPI, Python 3.12+, Pydantic, yfinance, yahooquery, FMP gateway scaffold, pandas, scipy
|
||||
- Data: SEC, DART, EDINET, FRED, OECD, DBnomics, Yahoo Finance
|
||||
- AI: Gemini for qualitative analysis only
|
||||
- Storage: SQLite by default
|
||||
@@ -82,6 +85,16 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Verification
|
||||
|
||||
```bash
|
||||
pytest tests -q
|
||||
cd apps/web
|
||||
npm run typecheck
|
||||
npm run build
|
||||
npm run e2e
|
||||
```
|
||||
|
||||
### Local URLs
|
||||
|
||||
- Frontend: [http://localhost:3000](http://localhost:3000)
|
||||
@@ -90,11 +103,25 @@ npm run dev
|
||||
|
||||
## Recent Work
|
||||
|
||||
- v2 refactor foundation: baseline measurements in `docs/baseline-2026-04.md`, CI workflow, pytest smoke tests, and Playwright route smoke tests
|
||||
- Data Gateway scaffold: typed `DataGateway` contract, chained providers, TTL cache wrapper, provider metrics, and a flag-gated `/api/market/quote/{ticker}` migration path via `ATLAS_FLAG_GATEWAY=true`
|
||||
- Central terminal state: Zustand-backed `useTerminal` store for active symbol, page context, recent symbols, watchlist, currency, theme, layouts, and Copilot context
|
||||
- Copilot context injection: right rail chat now sends terminal context to `/api/copilot/chat` on every turn
|
||||
- Keyboard workflow: `Cmd/Ctrl+K` and `G` focus ticker search, `/` focuses Copilot, `W` adds the active symbol to watchlist, and `P/M/N` navigate Portfolio/Macro/News
|
||||
- Smarter ticker search: company-name and Korean aliases now resolve suggestions such as Berkshire Hathaway, SK hynix, Samsung Electronics, Toyota, Novo Nordisk, and common ETFs/commodities
|
||||
- Portfolio and FX reliability: exchange-aware Novo Nordisk EUR handling, faster FX/portfolio repeat loads, and cleaner local artifact ignore rules
|
||||
- Morgan Stanley-inspired redesign across the shell, overview, research, valuation, technical, macro, settings, and report flows
|
||||
- Shared chart palette and UI primitives for a more consistent desktop terminal experience
|
||||
- Research dashboard performance fixes for faster repeat loads and less blocking on page open
|
||||
- Improved macro failure states, report messaging, tooltip formatting, and chart legibility
|
||||
|
||||
## Refactor Roadmap
|
||||
|
||||
- Phase 0: Foundation safety net, baseline docs, CI, backend smoke tests, frontend e2e smoke tests
|
||||
- Phase 1: Data Gateway migration behind `ATLAS_FLAG_GATEWAY`, starting with low-risk quote data before wider overview/profile routes
|
||||
- Phase 2: Global terminal state through Zustand, Copilot context, and keyboard-first terminal navigation
|
||||
- Next phases: encrypted credential vault, peer comparison, earnings-call delta analysis, and smaller institutional feature gaps
|
||||
|
||||
## Why This Project
|
||||
|
||||
ATLAS Terminal started as an attempt to build a personal Bloomberg-lite for retail investing workflows: high information density, clean narrative structure, and a hard separation between AI-generated language and deterministic financial computation.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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*",
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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))
|
||||
@@ -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]: ...
|
||||
@@ -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(),
|
||||
]
|
||||
)
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user