From 38c56a5a436248b152bfb46fc8f704f3234d8a88 Mon Sep 17 00:00:00 2001 From: shawnkim1997 Date: Sat, 21 Mar 2026 17:08:00 +0000 Subject: [PATCH] feat: deliver multi-asset analytics, OCR exchange selection, and heatmap UX Add asset-type aware market/overview flows, portfolio OCR reverse-engineering with exchange overrides, and interactive index heatmap features. Update README with recent updates and wire backend/frontend APIs for FX matrix, exchange options, and improved portfolio editing flows. Made-with: Cursor --- atlas-terminal/README.md | 7 + .../app/components/markets/HeatmapSection.tsx | 119 +++++ .../components/overview/CommodityOverview.tsx | 88 ++++ .../app/components/overview/ETFOverview.tsx | 84 +++ .../components/overview/EquityOverview.tsx | 74 +++ .../apps/web/src/app/components/sidebar.tsx | 4 +- .../apps/web/src/app/earnings/page.tsx | 18 +- atlas-terminal/apps/web/src/app/globals.css | 199 +++++++ .../apps/web/src/app/lib/ticker-alias.ts | 36 ++ .../apps/web/src/app/lib/use-ticker.ts | 6 +- .../apps/web/src/app/markets/page.tsx | 144 +++++- atlas-terminal/apps/web/src/app/page.tsx | 127 +---- .../apps/web/src/app/portfolio/page.tsx | 481 ++++++++++++++++- .../apps/web/src/app/research/page.tsx | 23 +- .../apps/web/src/app/screener/page.tsx | 173 +++++++ .../apps/web/src/app/valuation/page.tsx | 25 +- atlas-terminal/claude.md | 485 ++++++++++++++++++ atlas-terminal/server/main.py | 4 +- atlas-terminal/server/models/schemas.py | 2 + atlas-terminal/server/routers/fx.py | 45 +- atlas-terminal/server/routers/market_data.py | 153 +++++- atlas-terminal/server/routers/markets.py | 14 + atlas-terminal/server/routers/portfolio.py | 128 ++++- atlas-terminal/server/routers/screener.py | 34 ++ atlas-terminal/server/services/backtester.py | 47 ++ .../server/services/commodity_analysis.py | 99 ++++ .../server/services/copilot_context.py | 24 + .../server/services/etf_analysis.py | 162 ++++++ .../server/services/exchange_resolver.py | 51 ++ atlas-terminal/server/services/heatmap.py | 71 +++ .../server/services/market_overview.py | 57 ++ atlas-terminal/server/services/screener.py | 47 ++ .../server/services/screenshot_ocr.py | 382 +++++++++----- .../server/services/sector_heatmap.py | 37 ++ atlas-terminal/server/utils/ticker_utils.py | 61 +++ 35 files changed, 3224 insertions(+), 287 deletions(-) create mode 100644 atlas-terminal/apps/web/src/app/components/markets/HeatmapSection.tsx create mode 100644 atlas-terminal/apps/web/src/app/components/overview/CommodityOverview.tsx create mode 100644 atlas-terminal/apps/web/src/app/components/overview/ETFOverview.tsx create mode 100644 atlas-terminal/apps/web/src/app/components/overview/EquityOverview.tsx create mode 100644 atlas-terminal/apps/web/src/app/lib/ticker-alias.ts create mode 100644 atlas-terminal/apps/web/src/app/screener/page.tsx create mode 100644 atlas-terminal/claude.md create mode 100644 atlas-terminal/server/routers/markets.py create mode 100644 atlas-terminal/server/routers/screener.py create mode 100644 atlas-terminal/server/services/backtester.py create mode 100644 atlas-terminal/server/services/commodity_analysis.py create mode 100644 atlas-terminal/server/services/copilot_context.py create mode 100644 atlas-terminal/server/services/etf_analysis.py create mode 100644 atlas-terminal/server/services/exchange_resolver.py create mode 100644 atlas-terminal/server/services/heatmap.py create mode 100644 atlas-terminal/server/services/market_overview.py create mode 100644 atlas-terminal/server/services/screener.py create mode 100644 atlas-terminal/server/services/sector_heatmap.py diff --git a/atlas-terminal/README.md b/atlas-terminal/README.md index 0ba0d5c..2dcb89a 100644 --- a/atlas-terminal/README.md +++ b/atlas-terminal/README.md @@ -25,3 +25,10 @@ npm run dev - **Frontend:** Next.js 14, TypeScript, Tailwind CSS, TradingView Lightweight Charts - **Backend:** FastAPI, Python 3.12+, yfinance, yahooquery, Google Gemini - **Database:** SQLite (local) / PostgreSQL (production) + +## Recent Updates + +- Added multi-asset analysis branching across Overview/Research/Valuation/Earnings for equity, ETF, and commodity futures. +- Implemented commodity and ETF market widgets, plus index-level stock heatmap with interactive index switching. +- Upgraded portfolio OCR with reverse-engineering logic, exchange selection (including SMSN -> `SMSN.L`), and inline edit/delete flows. +- Added portfolio exchange-aware recalculation and FX conversion matrix support for multi-currency display. diff --git a/atlas-terminal/apps/web/src/app/components/markets/HeatmapSection.tsx b/atlas-terminal/apps/web/src/app/components/markets/HeatmapSection.tsx new file mode 100644 index 0000000..c7749a4 --- /dev/null +++ b/atlas-terminal/apps/web/src/app/components/markets/HeatmapSection.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +type HeatmapStock = { + ticker: string; + name: string; + sector: string; + market_cap: number; + change_pct: number; +}; + +const INDEX_OPTIONS = [ + { id: "sp500", label: "S&P 500" }, + { id: "nasdaq100", label: "NASDAQ 100" }, + { id: "kospi", label: "KOSPI" }, + { id: "ftse100", label: "FTSE 100" }, +]; + +export function HeatmapSection({ + selectedIndex, + onSelectIndex, +}: { + selectedIndex: string; + onSelectIndex: (v: string) => void; +}) { + const [stocks, setStocks] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setLoading(true); + fetch(`/api/markets/heatmap/${selectedIndex}?top_n=50`) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => setStocks(Array.isArray(d?.stocks) ? d.stocks : [])) + .finally(() => setLoading(false)); + }, [selectedIndex]); + + const grouped = useMemo(() => { + const sectors: Record = {}; + for (const s of stocks) { + const k = s.sector || "Other"; + if (!sectors[k]) sectors[k] = []; + sectors[k].push(s); + } + return sectors; + }, [stocks]); + + const totalMcap = stocks.reduce((sum, s) => sum + (s.market_cap || 0), 0); + + return ( +
+
+

Stock Heatmap

+
+ {INDEX_OPTIONS.map((idx) => ( + + ))} +
+
+ + {loading ? ( +
Loading heatmap...
+ ) : ( +
+ {Object.entries(grouped).map(([sector, sectorStocks]) => { + const sectorMcap = sectorStocks.reduce((s, st) => s + (st.market_cap || 0), 0); + const sectorPct = totalMcap > 0 ? (sectorMcap / totalMcap) * 100 : 0; + return ( +
+
{sector}
+
+ {sectorStocks.map((stock) => { + const stockPct = sectorMcap > 0 ? (stock.market_cap / sectorMcap) * 100 : 0; + const intensity = Math.min(Math.abs(stock.change_pct) / 4, 1); + const isPositive = stock.change_pct >= 0; + const primaryLabel = selectedIndex === "kospi" ? (stock.name || stock.ticker) : stock.ticker; + const secondaryLabel = selectedIndex === "kospi" ? stock.ticker : ""; + return ( +
= 0 ? "+" : ""}${stock.change_pct}%`} + > + {primaryLabel} + {secondaryLabel ? {secondaryLabel} : null} + + {stock.change_pct >= 0 ? "+" : ""} + {stock.change_pct}% + +
+ ); + })} +
+
+ ); + })} +
+ )} +
+ ); +} + diff --git a/atlas-terminal/apps/web/src/app/components/overview/CommodityOverview.tsx b/atlas-terminal/apps/web/src/app/components/overview/CommodityOverview.tsx new file mode 100644 index 0000000..2894703 --- /dev/null +++ b/atlas-terminal/apps/web/src/app/components/overview/CommodityOverview.tsx @@ -0,0 +1,88 @@ +"use client"; + +interface CommodityOverviewProps { + ticker: string; + data: Record; +} + +export function CommodityOverview({ ticker, data }: CommodityOverviewProps) { + const seasonal = data?.seasonal_pattern || {}; + const correlations = data?.correlation_matrix || {}; + const related = Array.isArray(data?.related_assets) ? data.related_assets : []; + return ( +
+

+ {ticker} Commodity Overview +

+
+
{data?.name || ticker}
+
+ {data?.price != null ? `$${Number(data.price).toFixed(2)}` : "—"} +
+
+ +
+ {[ + { label: "Open Interest", value: data?.open_interest?.toLocaleString?.() || "—" }, + { label: "Volume", value: data?.volume?.toLocaleString?.() || "—" }, + { label: "52W High", value: data?.high_52w != null ? `$${Number(data.high_52w).toFixed(2)}` : "—" }, + { label: "52W Low", value: data?.low_52w != null ? `$${Number(data.low_52w).toFixed(2)}` : "—" }, + ].map((m) => ( +
+
{m.label}
+
{m.value}
+
+ ))} +
+ +
+

Seasonal Pattern (10Y avg monthly)

+
+ {Array.from({ length: 12 }, (_, i) => i + 1).map((m) => { + const v = seasonal?.[m] ?? 0; + return ( +
+
M{m}
+
= 0 ? "text-accent-green" : "text-accent-red"}`}> + {v >= 0 ? "+" : ""} + {v}% +
+
+ ); + })} +
+
+ + {related.length > 0 && ( +
+

Related Assets

+
+ {related.map((r: Record) => ( +
+
{r.symbol}
+
{r.price != null ? `$${Number(r.price).toFixed(2)}` : "—"}
+
= 0 ? "text-accent-green" : "text-accent-red"}`}> + {Number(r.change_pct || 0) >= 0 ? "+" : ""} + {r.change_pct ?? 0}% +
+
+ ))} +
+
+ )} + +
+

Correlation Matrix (1Y)

+
+ {Object.keys(correlations).length === 0 &&
No correlation data
} + {Object.entries(correlations).map(([k, v]) => ( +
+ {k} + {String(v)} +
+ ))} +
+
+
+ ); +} diff --git a/atlas-terminal/apps/web/src/app/components/overview/ETFOverview.tsx b/atlas-terminal/apps/web/src/app/components/overview/ETFOverview.tsx new file mode 100644 index 0000000..d91c1bc --- /dev/null +++ b/atlas-terminal/apps/web/src/app/components/overview/ETFOverview.tsx @@ -0,0 +1,84 @@ +"use client"; + +interface ETFOverviewProps { + ticker: string; + data: Record; +} + +export function ETFOverview({ ticker, data }: ETFOverviewProps) { + const returns = data?.returns || {}; + const risk = data?.risk || {}; + const holdings = Array.isArray(data?.holdings) ? data.holdings : []; + return ( +
+

+ {ticker} ETF Overview +

+
+
{data?.name || ticker}
+
+ {data?.price != null ? `$${Number(data.price).toFixed(2)}` : "—"} +
+
+ +
+ {[ + { label: "Category", value: data?.category || "N/A" }, + { label: "AUM", value: data?.aum ? `$${(Number(data.aum) / 1e9).toFixed(1)}B` : "—" }, + { label: "Expense Ratio", value: data?.expense_ratio != null ? `${(Number(data.expense_ratio) * 100).toFixed(2)}%` : "—" }, + { label: "NAV", value: data?.nav != null ? `$${Number(data.nav).toFixed(2)}` : "—" }, + { label: "52W High", value: data?.high_52w != null ? `$${Number(data.high_52w).toFixed(2)}` : "—" }, + { label: "52W Low", value: data?.low_52w != null ? `$${Number(data.low_52w).toFixed(2)}` : "—" }, + { label: "1Y Return", value: returns?.["1y"] != null ? `${returns["1y"]}%` : "—" }, + { label: "YTD Return", value: returns?.ytd != null ? `${returns.ytd}%` : "—" }, + ].map((m) => ( +
+
{m.label}
+
{m.value}
+
+ ))} +
+ +
+

Performance

+
+ {["1m", "3m", "6m", "ytd", "1y", "3y", "5y"].map((k) => ( + + {k.toUpperCase()}: {returns?.[k] != null ? `${returns[k]}%` : "—"} + + ))} +
+
+ + {holdings.length > 0 && ( +
+

Top Holdings

+
+ {holdings.slice(0, 10).map((h: Record, i: number) => ( +
+ {h.symbol || h.name || "—"} + + {h.weight_pct != null ? `${(Number(h.weight_pct) * 100).toFixed(2)}%` : "—"} + +
+ ))} +
+
+ )} + +
+ {[ + { label: "Sharpe", value: risk?.sharpe }, + { label: "Sortino", value: risk?.sortino }, + { label: "Max DD", value: risk?.max_drawdown != null ? `${risk.max_drawdown}%` : null }, + { label: "Volatility", value: risk?.volatility != null ? `${risk.volatility}%` : null }, + ].map((r) => ( +
+
{r.label}
+
{r.value ?? "—"}
+
+ ))} +
+
+ ); +} diff --git a/atlas-terminal/apps/web/src/app/components/overview/EquityOverview.tsx b/atlas-terminal/apps/web/src/app/components/overview/EquityOverview.tsx new file mode 100644 index 0000000..2a3e0ac --- /dev/null +++ b/atlas-terminal/apps/web/src/app/components/overview/EquityOverview.tsx @@ -0,0 +1,74 @@ +"use client"; + +interface EquityOverviewProps { + ticker: string; + sector: Record | null; + health: Record | null; +} + +export function EquityOverview({ ticker, sector, health }: EquityOverviewProps) { + const metrics = [ + { label: "Sector", value: sector?.sector || "—" }, + { label: "Industry", value: sector?.industry || "—" }, + { label: "Market Cap", value: sector?.market_cap ? `$${(Number(sector.market_cap) / 1e9).toFixed(1)}B` : "—" }, + { label: "P/E Ratio", value: sector?.pe_ratio != null ? Number(sector.pe_ratio).toFixed(1) : "—" }, + { label: "Beta", value: sector?.beta != null ? Number(sector.beta).toFixed(2) : "—" }, + { label: "Div Yield", value: sector?.dividend_yield != null ? `${Number(sector.dividend_yield).toFixed(2)}%` : "—" }, + { label: "52W High", value: sector?.fifty_two_week_high != null ? `$${Number(sector.fifty_two_week_high).toFixed(2)}` : "—" }, + { label: "52W Low", value: sector?.fifty_two_week_low != null ? `$${Number(sector.fifty_two_week_low).toFixed(2)}` : "—" }, + ]; + + return ( +
+

+ {ticker} Overview +

+ {sector?.current_price != null && ( +

${Number(sector.current_price).toFixed(2)}

+ )} +
+ {metrics.map((m) => ( +
+
{m.label}
+
{m.value}
+
+ ))} +
+
+ + + + +
+
+

DuPont Analysis

+ {!!health?.dupont ? ( +
+ {[ + { label: "ROE", value: health.dupont.roe }, + { label: "Net Profit Margin", value: health.dupont.npm }, + { label: "Asset Turnover", value: health.dupont.asset_turnover }, + { label: "Equity Multiplier", value: health.dupont.equity_multiplier }, + ].map((d) => ( +
+ {d.label} + {d.value != null ? Number(d.value).toFixed(2) : "—"} +
+ ))} +
+ ) : ( +
No data
+ )} +
+
+ ); +} + +function Card({ title, value }: { title: string; value: string }) { + return ( +
+

{title}

+
{value}
+
+ ); +} diff --git a/atlas-terminal/apps/web/src/app/components/sidebar.tsx b/atlas-terminal/apps/web/src/app/components/sidebar.tsx index 0d916c9..0bc0013 100644 --- a/atlas-terminal/apps/web/src/app/components/sidebar.tsx +++ b/atlas-terminal/apps/web/src/app/components/sidebar.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { useState, useEffect } from "react"; +import { normalizeTickerInput } from "../lib/ticker-alias"; const NAV_ITEMS = [ { href: "/", label: "Overview", icon: "📊" }, @@ -11,6 +12,7 @@ const NAV_ITEMS = [ { href: "/markets", label: "Markets", icon: "🌍" }, { href: "/earnings", label: "Earnings", icon: "📅" }, { href: "/news", label: "News", icon: "📰" }, + { href: "/screener", label: "Screener", icon: "🎯" }, { href: "/portfolio", label: "Portfolio", icon: "💼" }, { href: "/filings", label: "Filings", icon: "📑" }, ]; @@ -33,7 +35,7 @@ export function Sidebar() { }, []); function handleSearch() { - const val = input.trim().toUpperCase(); + const val = normalizeTickerInput(input); if (val) { setTickerLocal(val); localStorage.setItem("atlas_active_ticker", val); diff --git a/atlas-terminal/apps/web/src/app/earnings/page.tsx b/atlas-terminal/apps/web/src/app/earnings/page.tsx index d784bff..6c2f4fa 100644 --- a/atlas-terminal/apps/web/src/app/earnings/page.tsx +++ b/atlas-terminal/apps/web/src/app/earnings/page.tsx @@ -23,6 +23,7 @@ interface QuarterlyData { export default function EarningsPage() { const { ticker } = useTicker(); + const [assetType, setAssetType] = useState("equity"); const [history, setHistory] = useState([]); const [calendar, setCalendar] = useState(null); const [quarterly, setQuarterly] = useState([]); @@ -34,16 +35,31 @@ export default function EarningsPage() { fetch(`/api/earnings/${ticker}/history`).then((r) => r.ok ? r.json() : null), fetch(`/api/earnings/${ticker}/calendar`).then((r) => r.ok ? r.json() : null), fetch(`/api/earnings/${ticker}/quarterly`).then((r) => r.ok ? r.json() : null), - ]).then(([h, c, q]) => { + fetch(`/api/market/overview/${ticker}`).then((r) => r.ok ? r.json() : null), + ]).then(([h, c, q, o]) => { setHistory(h?.history || []); setCalendar(c); setQuarterly(q?.quarterly || []); + setAssetType(o?.asset_type || "equity"); setLoading(false); }).catch(() => setLoading(false)); }, [ticker]); if (loading) return
Loading...
; + if (assetType !== "equity") { + return ( +
+

+ {ticker} Earnings +

+
+ 해당 자산 유형({assetType})에는 Earnings 데이터가 없습니다. +
+
+ ); + } + return (

diff --git a/atlas-terminal/apps/web/src/app/globals.css b/atlas-terminal/apps/web/src/app/globals.css index 03741ed..622504e 100644 --- a/atlas-terminal/apps/web/src/app/globals.css +++ b/atlas-terminal/apps/web/src/app/globals.css @@ -38,3 +38,202 @@ body { input::placeholder { color: #6B7280; } + +.action-btn { + width: 32px; + height: 32px; + border: none; + border-radius: 6px; + background: transparent; + cursor: pointer; +} + +.edit-input { + width: 100%; + max-width: 120px; + background: #0A0A0F; + border: 1px solid #4DA6FF; + border-radius: 6px; + padding: 6px 10px; + color: #F3F4F6; + font-family: "JetBrains Mono", monospace; + font-size: 13px; + outline: none; +} + +.delete-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.portfolio-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; +} + +.currency-toggle { + display: flex; + gap: 4px; + background: #1A1A26; + border: 1px solid #2A2A3A; + border-radius: 8px; + padding: 3px; +} + +.currency-btn { + padding: 6px 14px; + border: none; + border-radius: 6px; + background: transparent; + color: #9CA3AF; + font-family: "JetBrains Mono", monospace; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.currency-btn:hover { + color: #F3F4F6; + background: #252536; +} + +.currency-btn.active { + background: #00D4AA; + color: #0A0A0F; +} + +.heatmap-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.index-toggle { + display: flex; + gap: 4px; + background: #1A1A26; + border: 1px solid #2A2A3A; + border-radius: 8px; + padding: 3px; +} + +.index-btn { + padding: 6px 16px; + border: none; + border-radius: 6px; + background: transparent; + color: #9CA3AF; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; +} + +.index-btn:hover { + color: #F3F4F6; + background: #252536; +} + +.index-btn.active { + background: #00D4AA; + color: #0A0A0F; +} + +.treemap-container { + display: flex; + flex-wrap: wrap; + gap: 2px; + border-radius: 8px; + overflow: hidden; + min-height: 400px; + background: #0A0A0F; +} + +.treemap-sector { + display: flex; + flex-direction: column; + min-width: 80px; + position: relative; +} + +.treemap-sector-label { + font-size: 10px; + font-weight: 600; + color: #9CA3AF; + padding: 4px 6px; + background: rgba(0, 0, 0, 0.3); + position: absolute; + top: 0; + left: 0; + z-index: 1; + border-radius: 4px 0 4px 0; + pointer-events: none; +} + +.treemap-stocks { + display: flex; + flex-wrap: wrap; + flex: 1; + gap: 1px; +} + +.treemap-cell { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-width: 60px; + min-height: 50px; + padding: 4px; + cursor: pointer; + transition: filter 0.15s, transform 0.1s; + border-radius: 2px; +} + +.treemap-cell:hover { + filter: brightness(1.15); + transform: scale(1.02); + z-index: 2; +} + +.treemap-ticker { + font-family: "JetBrains Mono", monospace; + font-size: 11px; + font-weight: 700; + color: #F3F4F6; +} + +.treemap-change { + font-family: "JetBrains Mono", monospace; + font-size: 10px; + font-weight: 600; + color: #F3F4F6; +} + +.treemap-subticker { + font-family: "JetBrains Mono", monospace; + font-size: 9px; + color: #9CA3AF; +} + +.heatmap-loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 400px; + color: #9CA3AF; + font-size: 14px; +} diff --git a/atlas-terminal/apps/web/src/app/lib/ticker-alias.ts b/atlas-terminal/apps/web/src/app/lib/ticker-alias.ts new file mode 100644 index 0000000..3c9951b --- /dev/null +++ b/atlas-terminal/apps/web/src/app/lib/ticker-alias.ts @@ -0,0 +1,36 @@ +const TICKER_ALIASES: Record = { + // Commodities (natural language -> futures ticker) + gold: "GC=F", + silver: "SI=F", + 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", + copper: "HG=F", + wheat: "ZW=F", + corn: "ZC=F", + soybeans: "ZS=F", + coffee: "KC=F", + sugar: "SB=F", + + // Popular commodity ETFs + gld: "GLD", + slv: "SLV", + uso: "USO", + ung: "UNG", + dbc: "DBC", + gsg: "GSG", +}; + +export function normalizeTickerInput(raw: string): string { + const cleaned = raw.trim(); + if (!cleaned) return ""; + const key = cleaned.toLowerCase(); + const mapped = TICKER_ALIASES[key] || cleaned; + return mapped.toUpperCase(); +} + diff --git a/atlas-terminal/apps/web/src/app/lib/use-ticker.ts b/atlas-terminal/apps/web/src/app/lib/use-ticker.ts index 50dfcca..e80e695 100644 --- a/atlas-terminal/apps/web/src/app/lib/use-ticker.ts +++ b/atlas-terminal/apps/web/src/app/lib/use-ticker.ts @@ -1,5 +1,6 @@ "use client"; import { useState, useEffect, useCallback } from "react"; +import { normalizeTickerInput } from "./ticker-alias"; const DEFAULT_TICKER = "AAPL"; const STORAGE_KEY = "atlas_active_ticker"; @@ -7,7 +8,8 @@ const EVENT_NAME = "atlas-ticker-change"; function getInitialTicker(): string { if (typeof window === "undefined") return DEFAULT_TICKER; - return localStorage.getItem(STORAGE_KEY) || DEFAULT_TICKER; + const saved = localStorage.getItem(STORAGE_KEY) || DEFAULT_TICKER; + return normalizeTickerInput(saved); } export function useTicker() { @@ -23,7 +25,7 @@ export function useTicker() { }, []); const setTicker = useCallback((val: string) => { - const upper = val.trim().toUpperCase(); + const upper = normalizeTickerInput(val); if (!upper) return; setTickerState(upper); localStorage.setItem(STORAGE_KEY, upper); diff --git a/atlas-terminal/apps/web/src/app/markets/page.tsx b/atlas-terminal/apps/web/src/app/markets/page.tsx index a6ee21b..45f6ae2 100644 --- a/atlas-terminal/apps/web/src/app/markets/page.tsx +++ b/atlas-terminal/apps/web/src/app/markets/page.tsx @@ -1,8 +1,10 @@ "use client"; import { useEffect, useState } from "react"; import { useTicker } from "../lib/use-ticker"; +import { HeatmapSection } from "../components/markets/HeatmapSection"; type StatementType = "income_statement" | "balance_sheet" | "cash_flow"; +type ViewTab = "overview" | "statements"; interface FinancialStatements { income_statement?: Record[]; @@ -10,6 +12,28 @@ interface FinancialStatements { cash_flow?: Record[]; } +interface OverviewItem { + name: string; + symbol: string; + price: number; + change_pct: number; +} + +interface MarketOverviewResponse { + indices?: OverviewItem[]; + commodities?: OverviewItem[]; + bonds?: OverviewItem[]; + crypto?: OverviewItem[]; + fx?: OverviewItem[]; + popular_etfs?: OverviewItem[]; +} + +interface SectorHeat { + sector: string; + etf: string; + change_pct: number; +} + const TABS: { key: StatementType; label: string }[] = [ { key: "income_statement", label: "Income Statement" }, { key: "balance_sheet", label: "Balance Sheet" }, @@ -107,6 +131,10 @@ export default function MarketsPage() { const { ticker } = useTicker(); const [data, setData] = useState(null); const [tab, setTab] = useState("income_statement"); + const [viewTab, setViewTab] = useState("overview"); + const [overview, setOverview] = useState(null); + const [sectors, setSectors] = useState([]); + const [selectedHeatmapIndex, setSelectedHeatmapIndex] = useState("sp500"); const [loading, setLoading] = useState(true); useEffect(() => { @@ -120,6 +148,17 @@ export default function MarketsPage() { .catch(() => setLoading(false)); }, [ticker]); + useEffect(() => { + fetch("/api/market/overview") + .then((r) => (r.ok ? r.json() : null)) + .then((d) => setOverview(d)) + .catch(() => setOverview(null)); + fetch("/api/market/sectors") + .then((r) => (r.ok ? r.json() : [])) + .then((d) => setSectors(Array.isArray(d) ? d : [])) + .catch(() => setSectors([])); + }, []); + if (loading) return (
@@ -137,7 +176,7 @@ export default function MarketsPage() { return vals.some(([, v]) => v != null); }) .map((r) => ({ - date: String(r.asOfDate || "").slice(0, 10), + date: String((r.asOfDate || r.period || "")).slice(0, 10), periodType: String(r.periodType || ""), data: r, })) @@ -223,16 +262,107 @@ export default function MarketsPage() { const filteredRows = rowDefs.filter(rowHasData); + function renderOverviewCard(title: string, items: OverviewItem[] | undefined) { + const indexMap: Record = { + "S&P 500": "sp500", + NASDAQ: "nasdaq100", + KOSPI: "kospi", + "FTSE 100": "ftse100", + }; + return ( +
+

{title}

+
+ {(items || []).map((item) => ( +
{ + if (title !== "Global Indices") return; + const target = indexMap[item.name]; + if (target) { + setSelectedHeatmapIndex(target); + document.getElementById("heatmap-section")?.scrollIntoView({ behavior: "smooth" }); + } + }} + > +
{item.name}
+
{item.price?.toLocaleString?.() ?? "—"}
+
= 0 ? "text-accent-green" : "text-accent-red"}`}> + {item.change_pct >= 0 ? "+" : ""} + {item.change_pct?.toFixed?.(2)}% +
+
+ ))} +
+
+ ); + } + return (

- {ticker} Financial Statements + {ticker} Markets

+ {/* Top Tabs */} +
+ {[ + { key: "overview", label: "Market Overview" }, + { key: "statements", label: "Financial Statements" }, + ].map((t) => ( + + ))} +
+ + {viewTab === "overview" ? ( +
+ {renderOverviewCard("Global Indices", overview?.indices)} + +
+

Sector Performance

+
+ {sectors.map((sector) => ( +
= 0 + ? `rgba(0, 212, 170, ${Math.min(Math.abs(sector.change_pct) / 5, 0.6)})` + : `rgba(255, 71, 87, ${Math.min(Math.abs(sector.change_pct) / 5, 0.6)})`, + }} + > + {sector.sector} + = 0 ? "text-accent-green" : "text-accent-red"}`}> + {sector.change_pct >= 0 ? "+" : ""} + {sector.change_pct}% + +
+ ))} +
+
+ {renderOverviewCard("Commodities", overview?.commodities)} + {renderOverviewCard("Popular ETFs", overview?.popular_etfs)} + {renderOverviewCard("Bonds", overview?.bonds)} + {renderOverviewCard("Crypto", overview?.crypto)} + {renderOverviewCard("FX", overview?.fx)} +
+ ) : ( + <> {/* Unit Note */}
Unit: Millions USD (except per-share data)
- {/* Tabs */} + {/* Statement Tabs */}
{TABS.map((t) => (
); } diff --git a/atlas-terminal/apps/web/src/app/page.tsx b/atlas-terminal/apps/web/src/app/page.tsx index 357bd71..e1c3626 100644 --- a/atlas-terminal/apps/web/src/app/page.tsx +++ b/atlas-terminal/apps/web/src/app/page.tsx @@ -1,29 +1,16 @@ "use client"; import { useEffect, useState } from "react"; import { useTicker } from "./lib/use-ticker"; - -interface HealthData { - dupont?: { roe?: number; npm?: number; asset_turnover?: number; equity_multiplier?: number }; - altman_z?: number; - red_flags?: string[]; -} - -interface SectorData { - sector?: string; - industry?: string; - market_cap?: number; - pe_ratio?: number; - dividend_yield?: number; - beta?: number; - fifty_two_week_high?: number; - fifty_two_week_low?: number; - current_price?: number; -} +import { EquityOverview } from "./components/overview/EquityOverview"; +import { ETFOverview } from "./components/overview/ETFOverview"; +import { CommodityOverview } from "./components/overview/CommodityOverview"; export default function OverviewPage() { const { ticker } = useTicker(); - const [sector, setSector] = useState(null); - const [health, setHealth] = useState(null); + const [sector, setSector] = useState | null>(null); + const [health, setHealth] = useState | null>(null); + const [overview, setOverview] = useState | null>(null); + const [assetType, setAssetType] = useState("equity"); const [loading, setLoading] = useState(true); useEffect(() => { @@ -31,103 +18,25 @@ export default function OverviewPage() { Promise.all([ fetch(`/api/market/sector/${ticker}`).then((r) => r.ok ? r.json() : null), fetch(`/api/market/health/${ticker}`).then((r) => r.ok ? r.json() : null), - ]).then(([s, h]) => { + fetch(`/api/market/overview/${ticker}`).then((r) => r.ok ? r.json() : null), + ]).then(([s, h, d]) => { setSector(s); setHealth(h); + setAssetType(d?.asset_type || "equity"); + setOverview(d?.data || null); setLoading(false); }).catch(() => setLoading(false)); }, [ticker]); if (loading) return ; - const metrics = [ - { label: "Sector", value: sector?.sector || "—" }, - { label: "Industry", value: sector?.industry || "—" }, - { label: "Market Cap", value: sector?.market_cap ? `$${(sector.market_cap / 1e9).toFixed(1)}B` : "—" }, - { label: "P/E Ratio", value: sector?.pe_ratio?.toFixed(1) || "—" }, - { label: "Beta", value: sector?.beta?.toFixed(2) || "—" }, - { label: "Div Yield", value: sector?.dividend_yield ? `${sector.dividend_yield.toFixed(2)}%` : "—" }, - { label: "52W High", value: sector?.fifty_two_week_high ? `$${sector.fifty_two_week_high.toFixed(2)}` : "—" }, - { label: "52W Low", value: sector?.fifty_two_week_low ? `$${sector.fifty_two_week_low.toFixed(2)}` : "—" }, - ]; - - const zScore = health?.altman_z; - const zColor = zScore && zScore > 2.99 ? "text-accent-green" : zScore && zScore > 1.81 ? "text-accent-yellow" : "text-accent-red"; - - return ( -
-

- {ticker} Overview -

- {sector?.current_price && ( -

- ${sector.current_price.toFixed(2)} -

- )} - - {/* Key Metrics Grid */} -
- {metrics.map((m) => ( -
-
{m.label}
-
{m.value}
-
- ))} -
- - {/* Health Section */} -
- {/* Altman Z-Score */} -
-

Altman Z-Score

-
- {zScore?.toFixed(2) || "—"} -
-
- {zScore && zScore > 2.99 ? "Safe Zone" : zScore && zScore > 1.81 ? "Grey Zone" : "Distress Zone"} -
-
- - {/* DuPont Analysis */} -
-

DuPont Analysis

- {health?.dupont ? ( -
- {[ - { label: "ROE", value: health.dupont.roe }, - { label: "Net Profit Margin", value: health.dupont.npm }, - { label: "Asset Turnover", value: health.dupont.asset_turnover }, - { label: "Equity Multiplier", value: health.dupont.equity_multiplier }, - ].map((d) => ( -
- {d.label} - - {d.value?.toFixed(2) || "—"} - -
- ))} -
- ) : ( -
No data
- )} -
-
- - {/* Red Flags */} - {health?.red_flags && health.red_flags.length > 0 && ( -
-

Red Flags

-
    - {health.red_flags.map((f, i) => ( -
  • - {f} -
  • - ))} -
-
- )} -
- ); + if (assetType === "etf") { + return ; + } + if (assetType === "commodity_future") { + return ; + } + return ; } function LoadingState() { diff --git a/atlas-terminal/apps/web/src/app/portfolio/page.tsx b/atlas-terminal/apps/web/src/app/portfolio/page.tsx index 41deac2..e3fa94b 100644 --- a/atlas-terminal/apps/web/src/app/portfolio/page.tsx +++ b/atlas-terminal/apps/web/src/app/portfolio/page.tsx @@ -11,17 +11,62 @@ interface Position { market_value?: number; pnl?: number; pnl_pct?: number; + confidence?: string; + method?: string; + avg_price_currency?: string; + stock_currency?: string; + account_currency?: string; + current_value_account?: number; + total_pnl?: number; + yf_ticker?: string; + avg_method?: string; + exchange?: string; } export default function PortfolioPage() { const [positions, setPositions] = useState([]); const [form, setForm] = useState({ ticker: "", quantity: "", avg_price: "" }); const [loading, setLoading] = useState(true); + const [isDragging, setIsDragging] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [ocrPositions, setOcrPositions] = useState([]); + const [ocrError, setOcrError] = useState(""); + const [ocrWarnings, setOcrWarnings] = useState([]); + const [ocrAccountCurrency, setOcrAccountCurrency] = useState("USD"); + const [editingId, setEditingId] = useState(null); + const [editValues, setEditValues] = useState({ qty: "", avgPrice: "" }); + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + const [displayCurrency, setDisplayCurrency] = useState("USD"); + const [fxRates, setFxRates] = useState>({}); + const [exchangeSelections, setExchangeSelections] = useState>({}); + const [exchangeOptions, setExchangeOptions] = useState>({}); useEffect(() => { fetchPortfolio(); + fetch("/api/fx/rates") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (!data) return; + setFxRates(data.rates || data); + }) + .catch(() => {}); }, []); + useEffect(() => { + if (!ocrPositions.length) return; + ocrPositions.forEach(async (pos, idx) => { + const rowKey = `${pos.ticker}-${idx}`; + const res = await fetch(`/api/portfolio/exchange-options/${pos.ticker}`); + const data = await res.json().catch(() => null); + const opts = Array.isArray(data?.options) ? data.options : []; + if (opts.length > 0) { + setExchangeOptions((prev) => ({ ...prev, [rowKey]: opts })); + const def = opts.find((o) => o.default) || opts[0]; + setExchangeSelections((prev) => ({ ...prev, [rowKey]: def.exchange })); + } + }); + }, [ocrPositions]); + async function fetchPortfolio() { setLoading(true); try { @@ -64,28 +109,209 @@ export default function PortfolioPage() { } } - const totalValue = positions.reduce((s, p) => s + (p.market_value || p.quantity * (p.current_price || p.avg_price)), 0); - const totalCost = positions.reduce((s, p) => s + p.quantity * p.avg_price, 0); + async function uploadForOcr(file: File) { + setIsProcessing(true); + setOcrError(""); + try { + const geminiKey = localStorage.getItem("atlas_gemini_key") || ""; + if (!geminiKey.trim()) { + setOcrPositions([]); + setOcrError("Gemini API 키가 없습니다. Settings에서 Gemini API Key를 먼저 저장하세요."); + return; + } + + const fd = new FormData(); + fd.append("file", file); + const res = await fetch("/api/portfolio/ocr", { + method: "POST", + body: fd, + headers: geminiKey ? { "x-gemini-api-key": geminiKey } : undefined, + }); + const data = await res.json().catch(() => null); + const message = + data?.error || + data?.detail || + (Array.isArray(data?.detail) ? data.detail.map((d: { msg?: string }) => d?.msg).filter(Boolean).join(", ") : "") || + (res.ok ? "" : `HTTP ${res.status}`); + if (!res.ok || data?.error) { + setOcrPositions([]); + setOcrError(message || "OCR failed"); + } else { + setOcrPositions(Array.isArray(data?.positions) ? data.positions : []); + setOcrWarnings(Array.isArray(data?.warnings) ? data.warnings : []); + setOcrAccountCurrency(data?.account_currency || data?.total_value?.currency || "USD"); + if (!Array.isArray(data?.positions) || data.positions.length === 0) { + setOcrError("OCR은 완료됐지만 포지션을 찾지 못했습니다. 표가 선명하게 보이는 스크린샷으로 다시 시도하세요."); + } + } + } catch { + setOcrPositions([]); + setOcrError("Network error during OCR upload"); + } finally { + setIsProcessing(false); + } + } + + async function importOcrPositions() { + if (!ocrPositions.length) return; + const failed: string[] = []; + for (let idx = 0; idx < ocrPositions.length; idx += 1) { + const p = ocrPositions[idx]; + try { + const rowKey = `${p.ticker}-${idx}`; + const res = await fetch("/api/portfolio/positions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ticker: p.ticker, + company_name: p.company_name || "", + quantity: p.quantity, + avg_price: p.avg_price, + currency: p.avg_price_currency || p.stock_currency || "USD", + exchange: exchangeSelections[rowKey] || p.exchange || "", + source: "ocr", + }), + }); + if (!res.ok) failed.push(p.ticker); + } catch { + failed.push(p.ticker); + } + } + if (failed.length > 0) { + setOcrError(`일부 저장 실패: ${failed.join(", ")}. Import Results를 유지합니다.`); + return; + } + setOcrPositions([]); + setOcrWarnings([]); + setExchangeSelections({}); + setExchangeOptions({}); + setOcrError(""); + await fetchPortfolio(); + } + + function updateOcrPosition(idx: number, key: "quantity" | "avg_price", value: string) { + setOcrPositions((prev) => + prev.map((p, i) => { + if (i !== idx) return p; + const n = Number(value); + if (Number.isNaN(n)) return p; + return { ...p, [key]: n }; + }), + ); + } + + async function handleExchangeChange(idx: number, exchange: string) { + const pos = ocrPositions[idx]; + if (!pos) return; + const rowKey = `${pos.ticker}-${idx}`; + setExchangeSelections((prev) => ({ ...prev, [rowKey]: exchange })); + const res = await fetch("/api/portfolio/ocr/recalculate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + account_currency: ocrAccountCurrency, + position: pos, + selected_exchange: exchange, + }), + }); + const data = await res.json().catch(() => null); + if (res.ok && data?.position) { + setOcrPositions((prev) => prev.map((p, i) => (i === idx ? { ...p, ...data.position, exchange } : p))); + } + } + + async function handleDelete(positionId: string) { + try { + const res = await fetch(`/api/portfolio/positions/${positionId}`, { method: "DELETE" }); + if (res.ok) { + setPositions((prev) => prev.filter((p) => p.id !== positionId)); + } + } finally { + setDeleteConfirmId(null); + } + } + + async function handleSaveEdit(positionId: string) { + const qty = parseFloat(editValues.qty); + const avgPrice = parseFloat(editValues.avgPrice); + if (isNaN(qty) || isNaN(avgPrice)) return; + const res = await fetch(`/api/portfolio/positions/${positionId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ quantity: qty, avg_price: avgPrice }), + }); + if (res.ok) { + setPositions((prev) => + prev.map((p) => (p.id === positionId ? { ...p, quantity: qty, avg_price: avgPrice } : p)), + ); + setEditingId(null); + await fetchPortfolio(); + } + } + + function convertAmount(amount: number, fromCurrency: string, toCurrency: string): number { + if (!amount || Number.isNaN(amount)) return 0; + const from = (fromCurrency || "USD").toUpperCase(); + const to = (toCurrency || "USD").toUpperCase(); + if (from === to) return amount; + const key = `${from}_${to}`; + const rate = fxRates[key] || 1; + return amount * rate; + } + + function getCurrencySymbol(currency: string): string { + const symbols: Record = { USD: "$", GBP: "£", KRW: "₩", EUR: "€", JPY: "¥", CNY: "¥" }; + return symbols[currency] || currency; + } + + function formatCurrencyValue(amount: number, currency: string): string { + const symbol = getCurrencySymbol(currency); + if (currency === "KRW" || currency === "JPY") return `${symbol}${Math.round(amount).toLocaleString()}`; + return `${symbol}${amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + } + + const totalValue = positions.reduce((s, p) => { + const valueRaw = p.market_value || p.quantity * (p.current_price || p.avg_price); + const src = (p.stock_currency || p.currency || "USD").toUpperCase(); + return s + convertAmount(valueRaw, src, displayCurrency); + }, 0); + const totalCost = positions.reduce((s, p) => { + const src = (p.currency || p.avg_price_currency || p.stock_currency || "USD").toUpperCase(); + return s + convertAmount(p.quantity * p.avg_price, src, displayCurrency); + }, 0); const totalGL = totalValue - totalCost; return (
-

Portfolio

+
+

Portfolio

+
+ {["USD", "GBP", "KRW", "EUR", "JPY"].map((cur) => ( + + ))} +
+
{/* Summary */}
Total Value
-
${totalValue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+
{formatCurrencyValue(totalValue, displayCurrency)}
Total Cost
-
${totalCost.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+
{formatCurrencyValue(totalCost, displayCurrency)}
Total P&L
= 0 ? "text-accent-green" : "text-accent-red"}`}> - {totalGL >= 0 ? "+" : ""}${totalGL.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + {totalGL >= 0 ? "+" : ""}{formatCurrencyValue(Math.abs(totalGL), displayCurrency)}
@@ -120,36 +346,248 @@ export default function PortfolioPage() {
+ {/* OCR Screenshot Import */} +
+

Import from Screenshot (OCR)

+
{ + e.preventDefault(); + setIsDragging(true); + }} + onDragLeave={(e) => { + e.preventDefault(); + setIsDragging(false); + }} + onDrop={(e) => { + e.preventDefault(); + setIsDragging(false); + const f = e.dataTransfer.files?.[0]; + if (f) uploadForOcr(f); + }} + onClick={() => document.getElementById("ocr-file-input")?.click()} + > + {isProcessing ? ( +

AI가 포지션을 분석중...

+ ) : ( + <> + 📸 +

Trading 212 / IBKR 스크린샷을 드래그하세요

+ + )} +
+ { + const f = e.target.files?.[0]; + if (f) uploadForOcr(f); + }} + /> + {ocrError &&

{ocrError}

} + {ocrPositions.length > 0 && ( +
+
+
+ Import Results: {ocrPositions.length} positions +
+
+ Account Currency: {ocrAccountCurrency} +
+
+ {ocrWarnings.length > 0 && ( +
+ Warnings ({ocrWarnings.length}) +
    + {ocrWarnings.map((w, i) =>
  • - {w}
  • )} +
+
+ )} +
+ + + + + + + + + + + + + + + {ocrPositions.map((p, i) => ( + + {(() => { + const rowKey = `${p.ticker}-${i}`; + const opts = exchangeOptions[rowKey] || []; + const hasMultiple = opts.length > 1; + return ( + <> + + + + + + + + + + ); + })()} + + ))} + +
TickerExchangeQtyAvgNowValue ({ocrAccountCurrency})P&L %Conf
{p.ticker} + {hasMultiple ? ( + + ) : ( + {(opts[0]?.exchange || p.exchange || p.stock_currency || "Default")} + )} + + updateOcrPosition(i, "quantity", e.target.value)} + className="w-28 bg-bg-primary border border-border rounded px-2 py-1" + /> + + updateOcrPosition(i, "avg_price", e.target.value)} + className="w-24 bg-bg-primary border border-border rounded px-2 py-1" + /> + {p.avg_price_currency || p.stock_currency || "USD"} + + {p.current_price != null ? `${p.current_price.toFixed(2)} ${p.stock_currency || ""}` : "—"} + + {p.current_value_account != null ? p.current_value_account.toLocaleString() : "—"} + = 0 ? "text-accent-green" : "text-accent-red"}`}> + {p.pnl_pct != null ? `${p.pnl_pct >= 0 ? "+" : ""}${p.pnl_pct.toFixed(2)}%` : "—"} + + {p.confidence === "high" ? "✅" : p.confidence === "medium" ? "⚠️" : "❌"} +
+
+
+ + +
+
+ )} +
+ {/* Positions Table */} {positions.length > 0 ? (
- {["Ticker", "Qty", "Avg Price", "Price", "Value", "P&L", "P&L %"].map((h) => ( + {["Ticker", "Exchange", "Qty", "Avg Price", "Price", "Value", "P&L", "P&L %", "Actions"].map((h) => ( ))} {positions.map((p, i) => { + const pid = p.id || `${p.ticker}-${i}`; const price = p.current_price || p.avg_price; - const value = p.market_value || p.quantity * price; - const gl = p.pnl ?? (value - p.quantity * p.avg_price); + const valueRaw = p.market_value || p.quantity * price; + const srcValueCurrency = (p.stock_currency || p.currency || "USD").toUpperCase(); + const value = convertAmount(valueRaw, srcValueCurrency, displayCurrency); + const costRaw = p.quantity * p.avg_price; + const srcCostCurrency = (p.currency || p.avg_price_currency || p.stock_currency || "USD").toUpperCase(); + const cost = convertAmount(costRaw, srcCostCurrency, displayCurrency); + const gl = p.pnl != null ? convertAmount(p.pnl, srcValueCurrency, displayCurrency) : (value - cost); const glPct = p.pnl_pct ?? ((price / p.avg_price - 1) * 100); return ( - + - - - - + + + + + + ); })} @@ -161,6 +599,19 @@ export default function PortfolioPage() { No positions yet. Add your first position above. ) : null} + + {deleteConfirmId && ( +
setDeleteConfirmId(null)}> +
e.stopPropagation()}> +

정말 삭제하시겠습니까?

+

이 작업은 되돌릴 수 없습니다.

+
+ + +
+
+
+ )} ); } diff --git a/atlas-terminal/apps/web/src/app/research/page.tsx b/atlas-terminal/apps/web/src/app/research/page.tsx index dee573d..07a424f 100644 --- a/atlas-terminal/apps/web/src/app/research/page.tsx +++ b/atlas-terminal/apps/web/src/app/research/page.tsx @@ -17,6 +17,7 @@ interface RadarData { export default function ResearchPage() { const { ticker } = useTicker(); + const [assetType, setAssetType] = useState("equity"); const [piotroski, setPiotroski] = useState(null); const [radar, setRadar] = useState(null); const [aiAnalysis, setAiAnalysis] = useState(""); @@ -28,9 +29,11 @@ export default function ResearchPage() { Promise.all([ fetch(`/api/market/piotroski/${ticker}`).then((r) => r.ok ? r.json() : null), fetch(`/api/market/radar/${ticker}`).then((r) => r.ok ? r.json() : null), - ]).then(([p, r]) => { + fetch(`/api/market/overview/${ticker}`).then((r) => r.ok ? r.json() : null), + ]).then(([p, r, o]) => { setPiotroski(p); setRadar(r); + setAssetType(o?.asset_type || "equity"); setLoading(false); }).catch(() => setLoading(false)); }, [ticker]); @@ -79,6 +82,7 @@ export default function ResearchPage() { {ticker} Research + {assetType === "equity" ? (
{/* Piotroski F-Score */}
@@ -127,6 +131,23 @@ export default function ResearchPage() { )}
+ ) : assetType === "etf" ? ( +
+

ETF Research

+
+ Holdings Analysis, Sector Breakdown, Overlap Analysis를 우선 제공합니다. + Piotroski/F-Score 및 기업 재무 레이더는 ETF에 적용되지 않습니다. +
+
+ ) : ( +
+

Commodity Research

+
+ Seasonal Analysis와 Supply/Demand 요인을 중심으로 분석합니다. + 주식 전용 지표(F-Score, DuPont)는 표시하지 않습니다. +
+
+ )} {/* AI Analysis */}
diff --git a/atlas-terminal/apps/web/src/app/screener/page.tsx b/atlas-terminal/apps/web/src/app/screener/page.tsx new file mode 100644 index 0000000..a182edc --- /dev/null +++ b/atlas-terminal/apps/web/src/app/screener/page.tsx @@ -0,0 +1,173 @@ +"use client"; +import { useState } from "react"; + +interface ScreenerRow { + ticker: string; + name?: string; + sector?: string; + market_cap?: number; + pe?: number; + div_yield?: number; + price?: number; + change_pct?: number; +} + +interface BacktestResult { + error?: string; + total_return_pct?: number; + benchmark_return_pct?: number; + alpha?: number; + sharpe_ratio?: number; +} + +export default function ScreenerPage() { + const [tab, setTab] = useState<"screener" | "backtest">("screener"); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [peMax, setPeMax] = useState("25"); + const [sector, setSector] = useState(""); + const [divMin, setDivMin] = useState(""); + + const [btTicker, setBtTicker] = useState("AAPL"); + const [strategy, setStrategy] = useState("sma_crossover"); + const [startDate, setStartDate] = useState("2024-01-01"); + const [endDate, setEndDate] = useState("2026-03-01"); + const [btResult, setBtResult] = useState(null); + const [btLoading, setBtLoading] = useState(false); + + async function runScreener() { + setLoading(true); + try { + const res = await fetch("/api/screener/search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + pe_max: peMax ? parseFloat(peMax) : undefined, + sector: sector || undefined, + div_yield_min: divMin ? parseFloat(divMin) : undefined, + }), + }); + const data = await res.json(); + setRows(Array.isArray(data) ? data : []); + } catch { + setRows([]); + } finally { + setLoading(false); + } + } + + async function runBacktest() { + setBtLoading(true); + try { + const res = await fetch("/api/screener/backtest", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ticker: btTicker, + strategy, + start_date: startDate, + end_date: endDate, + }), + }); + setBtResult(await res.json()); + } catch { + setBtResult(null); + } finally { + setBtLoading(false); + } + } + + return ( +
+

Stock Screener

+ +
+ + +
+ + {tab === "screener" ? ( +
+
+ setPeMax(e.target.value)} placeholder="P/E < 25" className="bg-bg-primary border border-border rounded px-3 py-2 text-sm" /> + setSector(e.target.value)} placeholder="Sector (optional)" className="bg-bg-primary border border-border rounded px-3 py-2 text-sm" /> + setDivMin(e.target.value)} placeholder="Div Yield > %" className="bg-bg-primary border border-border rounded px-3 py-2 text-sm" /> + +
+
+
{h}
{p.ticker}{p.quantity}${p.avg_price.toFixed(2)}${price.toFixed(2)}${value.toLocaleString(undefined, { minimumFractionDigits: 2 })}{p.exchange || "—"} + {editingId === pid ? ( + setEditValues((prev) => ({ ...prev, qty: e.target.value }))} + className="w-28 bg-bg-primary border border-accent-blue rounded px-2 py-1" + autoFocus + /> + ) : ( + p.quantity + )} + + {editingId === pid ? ( + setEditValues((prev) => ({ ...prev, avgPrice: e.target.value }))} + className="w-24 bg-bg-primary border border-accent-blue rounded px-2 py-1" + /> + ) : ( + <>${p.avg_price.toFixed(2)} + )} + {formatCurrencyValue(convertAmount(price, srcValueCurrency, displayCurrency), displayCurrency)}{formatCurrencyValue(value, displayCurrency)} = 0 ? "text-accent-green" : "text-accent-red"}`}> - {gl >= 0 ? "+" : ""}${gl.toFixed(2)} + {gl >= 0 ? "+" : ""}{formatCurrencyValue(Math.abs(gl), displayCurrency)} = 0 ? "text-accent-green" : "text-accent-red"}`}> {glPct >= 0 ? "+" : ""}{glPct.toFixed(1)}% + {editingId === pid ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+ + + {["Ticker", "Name", "Sector", "Price", "P/E", "MCap", "Change"].map((h) => ( + + ))} + + + + {rows.map((r, i) => ( + + + + + + + + + + ))} + +
{h}
{r.ticker}{r.name || "—"}{r.sector || "—"}{r.price != null ? `$${r.price.toFixed(2)}` : "—"}{r.pe != null ? r.pe.toFixed(1) : "—"}{r.market_cap ? `${(r.market_cap / 1e9).toFixed(1)}B` : "—"}= 0) ? "text-accent-green" : "text-accent-red"}`}> + {r.change_pct != null ? `${r.change_pct >= 0 ? "+" : ""}${r.change_pct.toFixed(2)}%` : "—"} +
+
+
+ ) : ( +
+
+ setBtTicker(e.target.value.toUpperCase())} placeholder="Ticker" className="bg-bg-primary border border-border rounded px-3 py-2 text-sm" /> + + setStartDate(e.target.value)} className="bg-bg-primary border border-border rounded px-3 py-2 text-sm" /> + setEndDate(e.target.value)} className="bg-bg-primary border border-border rounded px-3 py-2 text-sm" /> + +
+ {btResult && !btResult.error && ( +
+ + + + +
+ )} + {btResult?.error &&

{btResult.error}

} +
+ )} +

+ ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/atlas-terminal/apps/web/src/app/valuation/page.tsx b/atlas-terminal/apps/web/src/app/valuation/page.tsx index cd71cc8..7bac61d 100644 --- a/atlas-terminal/apps/web/src/app/valuation/page.tsx +++ b/atlas-terminal/apps/web/src/app/valuation/page.tsx @@ -51,6 +51,7 @@ type ValuationTab = "dcf" | "sensitivity" | "montecarlo" | "tornado" | "reverse" export default function ValuationPage() { const { ticker } = useTicker(); + const [assetType, setAssetType] = useState("equity"); const [inputs, setInputs] = useState(null); const [consensus, setConsensus] = useState(null); const [dcfResult, setDcfResult] = useState(null); @@ -79,9 +80,11 @@ export default function ValuationPage() { fetch(`/api/valuation/dcf-inputs/${ticker}`).then((r) => r.ok ? r.json() : null), fetch(`/api/valuation/consensus/${ticker}`).then((r) => r.ok ? r.json() : null), fetch(`/api/valuation/smart-defaults/${ticker}`).then((r) => r.ok ? r.json() : null), - ]).then(([i, c, d]) => { + fetch(`/api/market/overview/${ticker}`).then((r) => r.ok ? r.json() : null), + ]).then(([i, c, d, o]) => { setInputs(i); setConsensus(c); + setAssetType(o?.asset_type || "equity"); if (d?.wacc) setWacc(d.wacc); if (d?.terminal_growth) setTerminalGrowth(d.terminal_growth); if (d?.fcf_growth) setFcfGrowth(d.fcf_growth); @@ -169,6 +172,26 @@ export default function ValuationPage() { if (loading) return
Loading...
; + if (assetType !== "equity") { + return ( +
+

+ {ticker} Valuation +

+
+

+ {assetType === "etf" ? "ETF Valuation Mode" : "Commodity Valuation Mode"} +

+
+ {assetType === "etf" + ? "NAV Premium/Discount, Expense 비교, Tracking Error 중심으로 평가합니다. DCF는 주식(EQUITY) 전용입니다." + : "Futures Curve(Contango/Backwardation), Cost of Carry 중심으로 평가합니다. DCF는 주식(EQUITY) 전용입니다."} +
+
+
+ ); + } + return (

diff --git a/atlas-terminal/claude.md b/atlas-terminal/claude.md new file mode 100644 index 0000000..4239c3c --- /dev/null +++ b/atlas-terminal/claude.md @@ -0,0 +1,485 @@ +# claude.md — ATLAS Terminal Build Guide + +> 이 문서는 Claude Code 에이전트가 프로젝트를 빌드할 때 참조하는 마스터 가이드입니다. +> 모든 에이전트는 작업 시작 전 이 문서를 반드시 읽어야 합니다. +> **마지막 수정: 2026-03-21** + +--- + +## 1. Project Identity + +| 항목 | 내용 | +|------|------| +| **이름** | ATLAS Terminal (Advanced Terminal for Liquid Asset Surveillance) | +| **목적** | 개인 투자자에게 기관급 투자 리서치를 단일 인터페이스로 제공 | +| **핵심 철학** | 금융 정보 비대칭 해소 — 기술로 리테일 투자자의 무기를 평등하게 | +| **디자인** | Bloomberg Terminal의 정보 밀도 + Notion의 깔끔함 + 다크 모드 | +| **스택** | Next.js 14 (App Router) + FastAPI + SQLite/PostgreSQL + Gemini API | +| **프로젝트 루트** | `/Users/seonpil/Library/Mobile Documents/com~apple~CloudDocs/Documents/FQDC Project/atlas-terminal/` | + +--- + +## 2. Architecture Principles (절대 규칙) + +### 2.1 하이브리드 분리 원칙 +``` +정성 데이터 (Qualitative) → LLM (Gemini) → 텍스트 분석만 +정량 데이터 (Quantitative) → Pandas + yfinance/yahooquery → 숫자/계산만 +``` +- **절대 LLM에 숫자 계산을 맡기지 않는다** — LLM은 텍스트 분석, 번역, 요약만 담당 +- 모든 재무 지표(DCF, DuPont, Altman Z, F-Score)는 Python 코드로 계산 +- 이 원칙을 위반하면 비용 폭발 + 정확도 하락 + +### 2.2 토큰 최적화 (비용 통제) +- 10-K 원문을 LLM에 보내기 전 반드시 `sec_parser.py`로 Item 1A~9A만 추출 +- `text_chunker.py`의 `smart_chunk()`로 10,000자 이내로 압축 (head+tail 보존) +- Gemini 호출은 탭당 최대 1~2회로 제한 +- 429 에러 시 `_generate_with_retry()`로 60초 대기 후 재시도 + +### 2.3 다단계 폴백 체인 +모든 외부 API 호출은 아래 순서를 따른다: +``` +yfinance (1순위) → yahooquery (2순위) → fast_info (3순위) → info (4순위) +→ balance_sheet/cashflow (5순위) → TTM 분기 합산 (6순위) → 수동 입력 (최후) +``` +- 모든 숫자 파싱에 `_safe_float()` 사용 (`server/utils/safe_float.py`) +- 실패 시 빈 DataFrame 또는 None 반환, 절대 에러를 UI에 노출하지 않음 + +### 2.4 모듈 분리 원칙 +- **한 파일 = 한 책임** — 단일 파일 3000줄 금지 +- 파일당 최대 300줄 목표 +- 비즈니스 로직 → `server/services/`, API 엔드포인트 → `server/routers/` +- 프론트엔드 컴포넌트는 기능별로 분리 + +### 2.5 Multi-Key Column Lookup (중요!) +yfinance와 yahooquery는 같은 데이터의 컬럼명이 다르다: +- yfinance: `"Total Revenue"` (띄어쓰기) +- yahooquery: `"TotalRevenue"` (CamelCase) + +프론트엔드에서 **파이프 구분자 패턴**으로 해결: +```tsx +function getValue(periodData: Record, key: string) { + const keys = key.split("|"); + for (const k of keys) { + const v = periodData[k.trim()]; + if (v != null && typeof v === "number") return v; + } + return null; +} +// 사용 예: getValue(data, "TotalRevenue|Total Revenue|Revenue") +``` + +--- + +## 3. 현재 File Structure (실제 아키텍처) + +``` +atlas-terminal/ +├── apps/web/ # Next.js 14 프론트엔드 +│ ├── src/app/ +│ │ ├── layout.tsx # 루트 레이아웃 (3-panel: Sidebar + Main + ChatPanel) +│ │ ├── page.tsx # Overview (Sector, DuPont, Altman Z) +│ │ ├── globals.css # Tailwind + Terminal Noir 기본 스타일 +│ │ ├── research/page.tsx # 10-K AI 분석 + Risk Factors +│ │ ├── valuation/page.tsx # 5-Tab: DCF, Sensitivity, Monte Carlo, Tornado, Reverse DCF +│ │ ├── technical/page.tsx # TradingView 캔들차트 + RSI/MACD/Bollinger/Fibonacci/MA +│ │ ├── markets/page.tsx # 재무제표 테이블 (YoY Growth + Margin %) +│ │ ├── earnings/page.tsx # EPS Beat/Miss + Revenue + Next Earnings +│ │ ├── news/page.tsx # Split-view: 기사 리스트 + iframe 원문 +│ │ ├── portfolio/page.tsx # 포지션 CRUD + Risk Metrics +│ │ ├── filings/page.tsx # SEC 10-K 원문 (5개 섹션 탭 + AI 요약) +│ │ ├── settings/page.tsx # API Key 관리 +│ │ ├── components/ +│ │ │ ├── sidebar.tsx # 좌측 네비게이션 (10개 메뉴) +│ │ │ ├── ticker-bar.tsx # 상단 실시간 지수 바 (S&P, NASDAQ, KOSPI, BTC) +│ │ │ └── chat-panel.tsx # 우측 AI Copilot 채팅 +│ │ └── lib/ +│ │ ├── use-ticker.ts # 티커 상태 훅 (localStorage + CustomEvent) +│ │ └── api.ts # API 클라이언트 유틸 +│ ├── next.config.mjs # /api/* → localhost:8000 프록시 +│ ├── tailwind.config.ts # Terminal Noir 컬러 토큰 +│ └── package.json +│ +├── server/ # FastAPI 백엔드 +│ ├── main.py # FastAPI app + 14개 라우터 등록 +│ ├── routers/ # API 엔드포인트 (14개 라우터) +│ │ ├── analysis.py # POST /api/analysis — Gemini LLM 분석 +│ │ ├── chat.py # /api/chat — AI Copilot +│ │ ├── crypto.py # /api/crypto — 암호화폐 가격 +│ │ ├── earnings.py # /api/earnings/{ticker}/history|calendar|quarterly +│ │ ├── edgar.py # /api/edgar — SEC 10-K 다운로드 + 파싱 +│ │ ├── estimates.py # /api/estimates — 애널리스트 추정치 +│ │ ├── financials.py # /api/financials/{ticker} — IS/BS/CF +│ │ ├── fx.py # /api/fx — 환율 +│ │ ├── insider.py # /api/insider/{ticker} — 내부자 거래 +│ │ ├── market_data.py # /api/market — 주가/섹터/헬스체크 +│ │ ├── news.py # /api/news/{ticker} — Finviz + Google RSS +│ │ ├── portfolio.py # /api/portfolio — CRUD + Risk +│ │ ├── technical.py # /api/technical/{ticker} — 기술적 지표 +│ │ └── valuation.py # /api/valuation — DCF, Sensitivity, Monte Carlo, Tornado, Reverse DCF +│ ├── services/ # 비즈니스 로직 (17개 서비스) +│ │ ├── crypto_fetcher.py # Bithumb + Binance API +│ │ ├── dcf_engine.py # excel_style_dcf, dcf_10y_2stage, reverse_dcf (scipy brentq) +│ │ ├── financial_metrics.py # DuPont, Altman Z, Piotroski F-Score +│ │ ├── financial_metrics_ext.py # 확장 지표 +│ │ ├── fx_fetcher.py # 환율 데이터 +│ │ ├── gemini_analysis.py # Gemini 분석 로직 +│ │ ├── gemini_service.py # Gemini API 래퍼 (retry, streaming) +│ │ ├── market_data.py # 시장 데이터 서비스 +│ │ ├── market_fetcher.py # yfinance/yahooquery 폴백 체인 +│ │ ├── monte_carlo.py # run_monte_carlo_dcf (numpy, 5000 sims) +│ │ ├── news_aggregator.py # RSS + Finviz + Google News +│ │ ├── risk_metrics.py # VaR, Sharpe, Sortino, MDD, Beta, Correlation +│ │ ├── screenshot_ocr.py # Gemini Vision OCR (포트폴리오 스크린샷) +│ │ ├── sec_parser.py # 10-K 다운로드 + HTML 파싱 + 섹션 추출 + 캐싱 +│ │ ├── sensitivity.py # build_sensitivity_matrix, build_tornado_data +│ │ ├── technical_analysis.py # RSI, MACD, Bollinger, Ichimoku, ADX, Fibonacci +│ │ └── text_chunker.py # smart_chunk, clean_text_for_llm +│ ├── db/ # 데이터베이스 레이어 +│ │ ├── unified_repo.py # SQLite/PostgreSQL 통합 인터페이스 +│ │ ├── cache.py # 캐시 저장소 +│ │ ├── database.py # SQLite 커넥션 +│ │ ├── pg_database.py # PostgreSQL 커넥션 +│ │ ├── portfolio_repo.py # 포트폴리오 SQLite CRUD +│ │ ├── pg_portfolio_repo.py # 포트폴리오 PostgreSQL CRUD +│ │ ├── pg_cache_repo.py # PostgreSQL 캐시 +│ │ ├── dashboard_repo.py # 대시보드 레이아웃 저장 +│ │ └── settings_repo.py # 설정 저장소 +│ ├── models/ +│ │ ├── schemas.py # Pydantic 모델 (요청/응답) +│ │ └── db.py # DB 모델 +│ ├── ai/ +│ │ ├── llm_router.py # LLM 프로바이더 라우팅 +│ │ └── context_builder.py # 컨텍스트 빌더 +│ └── utils/ +│ ├── safe_float.py # 안전한 숫자 파싱 +│ └── ticker_utils.py # 티커 유틸리티 +│ +├── claude.md # 이 파일 (AI 에이전트 매뉴얼) +├── requirements.txt # Python 의존성 +└── README.md +``` + +--- + +## 4. Design System — "Terminal Noir" + +### 4.1 Tailwind 컬러 토큰 (`tailwind.config.ts`) +```typescript +colors: { + bg: { + primary: "#0A0A0F", // 메인 배경 + secondary: "#12121A", // 서브 배경 + card: "#1A1A26", // 카드 서피스 + hover: "#252536", // 호버 상태 + }, + accent: { + green: "#00D4AA", // 상승, CTA, 활성 (민트 그린) + red: "#FF4757", // 하락, 경고 + yellow: "#FFD93D", // 주의, 하이라이트 + blue: "#4DA6FF", // 정보, 링크 + }, + text: { + primary: "#F3F4F6", // 주 텍스트 + secondary: "#9CA3AF", // 보조 텍스트 + muted: "#6B7280", // 약한 라벨 + }, + border: { DEFAULT: "#2A2A3A" }, +} +fontFamily: { + sans: ["Inter", "system-ui", "sans-serif"], + mono: ["JetBrains Mono", "monospace"], +} +``` + +### 4.2 UI 규칙 +- **숫자**: 양수 = `text-accent-green` + `+` 접두사, 음수 = `text-accent-red`, 폰트 = `font-mono` +- **카드**: `bg-bg-card border border-border rounded-lg p-5` +- **로딩**: `text-accent-green animate-pulse font-mono "Loading data..."` +- **AI 관련**: `text-accent-blue` 또는 인디고 계열 +- **에러**: `border-accent-red/30` 배경 + `text-accent-red` 텍스트 + +### 4.3 3-Panel 레이아웃 +``` +┌──────────────────────────────────────────────────────────┐ +│ [ATLAS TERMINAL] S&P 500 -1.51% NASDAQ +2.81% ... │ ← TickerBar (h-52px, fixed top) +├───────────┬──────────────────────────┬───────────────────┤ +│ │ │ │ +│ Sidebar │ Main Content │ AI Copilot │ +│ w-260px │ flex-1 │ w-380px │ +│ │ p-7 │ │ +│ Overview │ │ Ask me anything │ +│ Research │ (각 페이지 콘텐츠) │ about {ticker} │ +│ Valuation │ │ │ +│ Technical │ │ [Send] │ +│ Markets │ │ │ +│ Earnings │ │ │ +│ News │ │ │ +│ Portfolio │ │ │ +│ Filings │ │ │ +│ Settings │ │ │ +│ │ │ │ +└───────────┴──────────────────────────┴───────────────────┘ +``` + +--- + +## 5. API Endpoints (전체 목록) + +### 5.1 Market Data (`/api/market`) +| Method | Path | 설명 | +|--------|------|------| +| GET | `/api/market/sector/{ticker}` | 섹터, 산업, 시총, PE, 베타, 52주 | +| GET | `/api/market/health/{ticker}` | DuPont, Altman Z, Red Flags | +| GET | `/api/market/price/{ticker}` | 현재가, 변동률 | +| GET | `/api/market/indices` | S&P 500, NASDAQ, KOSPI, BTC | + +### 5.2 Financials (`/api/financials`) +| Method | Path | 설명 | +|--------|------|------| +| GET | `/api/financials/{ticker}` | 손익계산서 (yfinance → yahooquery 폴백) | +| GET | `/api/financials/balance/{ticker}` | 대차대조표 | +| GET | `/api/financials/cashflow/{ticker}` | 현금흐름표 | + +### 5.3 Valuation (`/api/valuation`) +| Method | Path | 설명 | +|--------|------|------| +| POST | `/api/valuation/dcf` | 10Y 2-Stage DCF | +| POST | `/api/valuation/sensitivity` | WACC × Terminal Growth 매트릭스 | +| POST | `/api/valuation/monte-carlo` | 5000회 시뮬레이션 + 히스토그램 | +| POST | `/api/valuation/tornado` | 변수별 민감도 순위 | +| POST | `/api/valuation/reverse-dcf` | 시장 내재 성장률 (scipy brentq) | + +### 5.4 Technical (`/api/technical`) +| Method | Path | 설명 | +|--------|------|------| +| GET | `/api/technical/{ticker}` | RSI, MACD, Bollinger, MA, ADX, Ichimoku | +| GET | `/api/technical/{ticker}/chart` | OHLCV 캔들 데이터 | + +### 5.5 Earnings (`/api/earnings`) +| Method | Path | 설명 | +|--------|------|------| +| GET | `/api/earnings/{ticker}/history` | EPS Beat/Miss 이력 | +| GET | `/api/earnings/{ticker}/calendar` | 다음 실적 발표일 | +| GET | `/api/earnings/{ticker}/quarterly` | 분기별 매출/순이익 | + +### 5.6 Insider (`/api/insider`) +| Method | Path | 설명 | +|--------|------|------| +| GET | `/api/insider/{ticker}` | 최근 내부자 거래 | +| GET | `/api/insider/{ticker}/holders` | 기관투자자 보유 현황 | + +### 5.7 News (`/api/news`) +| Method | Path | 설명 | +|--------|------|------| +| GET | `/api/news/{ticker}` | Finviz + Google News RSS | + +### 5.8 SEC EDGAR (`/api/edgar`) +| Method | Path | 설명 | +|--------|------|------| +| POST | `/api/edgar/download` | 10-K 다운로드 (sec-edgar-downloader) | +| GET | `/api/edgar/sections/{ticker}` | 10-K 섹션별 텍스트 (1A, 3, 7, 8, 9A) | + +### 5.9 기타 +| Prefix | 설명 | +|--------|------| +| `/api/analysis` | Gemini AI 분석 | +| `/api/chat` | AI Copilot 대화 | +| `/api/crypto` | 암호화폐 (Bithumb + Binance) | +| `/api/fx` | 환율 | +| `/api/estimates` | 애널리스트 추정치 | +| `/api/portfolio` | 포트폴리오 CRUD + Risk Metrics | + +--- + +## 6. Frontend Pages (10개) + +| 경로 | 파일 | 핵심 기능 | +|------|------|----------| +| `/` | `page.tsx` | Overview — 섹터/산업, 시총/PE/베타, Altman Z-Score, DuPont 분해 | +| `/research` | `research/page.tsx` | 10-K AI 분석 — MD&A + Risk Factors (Gemini) | +| `/valuation` | `valuation/page.tsx` | **5-Tab**: DCF, Sensitivity Matrix (WACC×TG), Monte Carlo (히스토그램), Tornado, Reverse DCF | +| `/technical` | `technical/page.tsx` | TradingView 캔들차트 (lightweight-charts), RSI/MACD/ATR 카드, 이동평균 테이블, Bollinger, Fibonacci | +| `/markets` | `markets/page.tsx` | 재무제표 테이블 — Revenue→EBITDA, YoY Growth 뱃지(초록/빨강), Margin % 행 | +| `/earnings` | `earnings/page.tsx` | 다음 실적일, EPS Beat/Miss 바차트, 분기 매출/순이익 | +| `/news` | `news/page.tsx` | Split-view — 좌측 기사 리스트(340px) + 우측 iframe 원문 보기 | +| `/portfolio` | `portfolio/page.tsx` | 포지션 관리 + Risk Metrics (VaR, Sharpe, MDD) | +| `/filings` | `filings/page.tsx` | SEC 10-K 원문 — 5개 섹션 탭 (Risk, MD&A, Financials, Legal, Controls) + AI Summary | +| `/settings` | `settings/page.tsx` | Gemini API Key, SEC Email 설정 | + +--- + +## 7. Ticker State Management + +**전역 티커 상태**는 React Context 없이 `localStorage` + `CustomEvent` 패턴으로 관리: + +```tsx +// apps/web/src/app/lib/use-ticker.ts +export function useTicker() { + const [ticker, setTicker] = useState(() => + localStorage.getItem("atlas-ticker") || "MSFT" + ); + + // 다른 컴포넌트의 변경도 감지 + useEffect(() => { + const handler = () => setTicker(localStorage.getItem("atlas-ticker") || "MSFT"); + window.addEventListener("ticker-changed", handler); + return () => window.removeEventListener("ticker-changed", handler); + }, []); + + const updateTicker = (t: string) => { + localStorage.setItem("atlas-ticker", t.toUpperCase()); + window.dispatchEvent(new CustomEvent("ticker-changed")); + }; + + return { ticker, setTicker: updateTicker }; +} +``` + +**사용법**: 모든 페이지에서 `const { ticker } = useTicker();`로 현재 티커 접근. +TickerBar의 검색창에서 `setTicker()`로 전역 변경. + +--- + +## 8. Key Algorithms + +### DCF 10Y 2-Stage (`server/services/dcf_engine.py`) +- Stage 1 (Y1-5): `FCF × (1 + growth)^t` +- Stage 2 (Y6-10): growth linearly fades to terminal growth rate +- Terminal Value at Y10: `FCF₁₀ × (1 + TG) / (WACC - TG)` +- Enterprise Value = sum of discounted FCFs + discounted TV + +### Reverse DCF (`server/services/dcf_engine.py`) +- scipy `brentq` root-finding: 현재 시가총액을 설명하는 성장률 역산 +- `f(g) = DCF(g) - market_cap = 0` 풀기 + +### Monte Carlo (`server/services/monte_carlo.py`) +- numpy로 5000회 시뮬레이션 +- growth, wacc, margin을 정규분포로 샘플링 +- 히스토그램 빈 + P(> current price) 계산 + +### Sensitivity Matrix (`server/services/sensitivity.py`) +- WACC (행) × Terminal Growth (열) 조합별 DCF 결과 매트릭스 +- Tornado: 각 변수를 ±20% 변동시켜 가격 영향 범위 계산, 영향력 순 정렬 + +### DuPont 3-Factor +`ROE = NPM × Asset Turnover × Equity Multiplier` + +### Altman Z-Score +`Z = 1.2(WC/TA) + 1.4(RE/TA) + 3.3(EBIT/TA) + 0.6(MC/TL) + 1.0(Sales/TA)` + +### Technical Indicators (`server/services/technical_analysis.py`) +- `ta` 라이브러리 사용: RSI, MACD, Bollinger Bands, Ichimoku Cloud, ADX +- `detect_signals()`: MA 크로스, RSI 과매수/과매도, MACD 시그널 +- `compute_fibonacci_levels()`: 52주 고/저 기반 되돌림 레벨 + +--- + +## 9. Development Rules (가드레일) + +### 코드 +- TypeScript strict mode (프론트), Python type hints (백엔드) +- 모든 API 호출 try/except; UI에 기술적 에러 노출 금지 +- 캐싱: 재무=TTL 300초, 10-K=영구, 환율=TTL 60초 +- `"use client"` — 모든 페이지 최상단에 필수 (App Router + hooks) + +### 프론트엔드 API 호출 패턴 +```tsx +// Next.js rewrites가 /api/* → localhost:8000/api/* 프록시 +// 따라서 상대경로로 호출: +fetch(`/api/market/sector/${ticker}`) +fetch(`/api/valuation/dcf`, { method: "POST", body: JSON.stringify(params) }) +``` + +### Gemini API +- 요청당 최대 25,000자, temperature 0.2~0.4 +- 429 → 60초 대기 × 3회 재시도 +- 스트리밍: MD&A/Risk 분석은 `stream=True` + +### yfinance 주의사항 +- `earnings_history` 컬럼명: `epsActual`, `epsEstimate`, `surprisePercent` (camelCase) +- `surprisePercent`는 소수 (0.0759 = 7.59%) → 프론트에서 `× 100` 필요 +- 날짜는 DataFrame index에 있음 (컬럼 아님) → `str(idx)[:10]` +- 연간 데이터에 TTM 행 혼재 가능 → `_filter_annual()` 적용 + +### 서버 실행 +```bash +# 백엔드 (포트 8000) +cd atlas-terminal +PYTHONPATH="." python3 -m uvicorn server.main:app --port 8000 --host 0.0.0.0 + +# 프론트엔드 (포트 3000) +cd atlas-terminal/apps/web +npm run dev +``` + +### Git +- 커밋: `feat:`, `fix:`, `docs:`, `refactor:` 접두사 +- 브랜치: `main`, `dev`, `feat/기능명` + +--- + +## 10. Dependencies + +### Python (`requirements.txt`) +``` +fastapi>=0.110.0 uvicorn[standard]>=0.29.0 +pydantic>=2.7.0 google-generativeai>=0.8.0 +anthropic>=0.39.0 openai>=1.50.0 +beautifulsoup4>=4.12.0 requests>=2.31.0 +pandas>=2.0.0 lxml>=4.9.0 +python-dotenv>=1.0.0 yfinance>=0.2.40 +yahooquery>=2.2.0 sec-edgar-downloader>=5.0.0 +feedparser>=6.0.0 ta>=0.11.0 +numpy scipy +aiosqlite>=0.20.0 asyncpg>=0.30.0 +pillow>=10.0.0 +``` + +### Node.js (`apps/web/package.json`) +``` +next: 14.2.35 react: ^18 +lightweight-charts: ^5.1.0 +tailwindcss: ^3.4.1 typescript: ^5 +``` + +--- + +## 11. Environment Variables +```env +GOOGLE_API_KEY= # Gemini API +SEC_EDGAR_EMAIL= # SEC 정책 필수 (10-K 다운로드용) +DATABASE_URL= # PostgreSQL (없으면 SQLite 자동) +NEWS_API_KEY= # 선택 +DART_API_KEY= # 한국 공시 (선택) +``` + +--- + +## 12. 알려진 이슈 및 주의사항 + +1. **`apps/web/app/` vs `apps/web/src/app/`**: 구 TanStack Start의 `app/` 디렉토리가 `_legacy_tanstack_app`으로 이름변경됨. Next.js는 `src/app/`을 사용. 절대 루트의 `app/` 디렉토리를 만들지 말 것. + +2. **Financial 데이터 혼합**: yahooquery는 12M + TTM 데이터를 섞어 반환할 수 있음. `server/routers/financials.py`의 `_filter_annual()` 함수가 TTM 필터링. + +3. **CORS**: `server/main.py`에서 `localhost:3000`, `localhost:3001`, `127.0.0.1:3000` 허용 설정됨. + +4. **Google Fonts**: `layout.tsx`의 ``에서 Inter + JetBrains Mono 로드. `` 태그 직접 삽입 방식. + +--- + +## 13. 미구현 기능 (TODO) + +- [ ] Widget-based 대시보드 (react-grid-layout) — 패키지 설치됨, 미구현 +- [ ] Enhanced AI Copilot — 인용/추론 단계 표시 +- [ ] Multi-LLM 시스템 (Gemini + Claude + OpenAI provider abstraction) — `ai/llm_router.py` 스캐폴딩만 +- [ ] Extended DuPont 5-Factor 분석 +- [ ] DART (한국 공시) / EDINET (일본 공시) 통합 +- [ ] 포트폴리오 스크린샷 OCR (Gemini Vision) — 서비스 존재, UI 미연결 +- [ ] ⌘K 글로벌 커맨드 팔레트 +- [ ] Sankey (자금흐름), Radar (재무건전성) 차트 +- [ ] GitHub README 자동 업데이트 워크플로우 + +--- + +*마지막 수정: 2026-03-21* diff --git a/atlas-terminal/server/main.py b/atlas-terminal/server/main.py index 0146b03..4a23bb5 100644 --- a/atlas-terminal/server/main.py +++ b/atlas-terminal/server/main.py @@ -44,7 +44,7 @@ app.add_middleware( ) # --- Mount routers --- -from server.routers import edgar, analysis, valuation, market_data, news, crypto, fx, portfolio, technical, financials, estimates, earnings, insider # noqa: E402 +from server.routers import edgar, analysis, valuation, market_data, news, crypto, fx, portfolio, technical, financials, estimates, earnings, insider, screener, markets # noqa: E402 app.include_router(edgar.router, prefix="/api/edgar", tags=["SEC EDGAR"]) app.include_router(analysis.router, prefix="/api/analysis", tags=["AI Analysis"]) @@ -55,10 +55,12 @@ app.include_router(estimates.router, prefix="/api/estimates", tags=["Estimates"] app.include_router(news.router, prefix="/api/news", tags=["News"]) app.include_router(crypto.router, prefix="/api/crypto", tags=["Crypto"]) app.include_router(fx.router, prefix="/api/fx", tags=["FX"]) +app.include_router(markets.router, prefix="/api/markets", tags=["Markets"]) app.include_router(portfolio.router, prefix="/api/portfolio", tags=["Portfolio"]) app.include_router(technical.router, prefix="/api/technical", tags=["Technical"]) app.include_router(earnings.router, prefix="/api/earnings", tags=["Earnings"]) app.include_router(insider.router, prefix="/api/insider", tags=["Insider Trading"]) +app.include_router(screener.router, prefix="/api/screener", tags=["Screener"]) @app.get("/health") diff --git a/atlas-terminal/server/models/schemas.py b/atlas-terminal/server/models/schemas.py index 5767ce3..b607c8c 100644 --- a/atlas-terminal/server/models/schemas.py +++ b/atlas-terminal/server/models/schemas.py @@ -75,6 +75,7 @@ class PortfolioPositionCreate(BaseModel): quantity: float avg_price: float currency: str = "USD" + exchange: str = "" source: str = "manual" @@ -182,6 +183,7 @@ class PortfolioPosition(BaseModel): quantity: float avg_price: float currency: str = "USD" + exchange: str = "" source: str = "manual" current_price: Optional[float] = None market_value: Optional[float] = None diff --git a/atlas-terminal/server/routers/fx.py b/atlas-terminal/server/routers/fx.py index c6cf73b..a25f91e 100644 --- a/atlas-terminal/server/routers/fx.py +++ b/atlas-terminal/server/routers/fx.py @@ -44,19 +44,44 @@ def _fetch_fx_rate(pair: str) -> float | None: @router.get( "/rates", response_model=FXRateResponse, - summary="Major FX rates", + summary="FX conversion matrix for major currencies", ) async def fx_rates(): - """Return current exchange rates for major currency pairs - (USD/KRW, USD/JPY, EUR/USD, GBP/USD, etc.). - """ + """Return conversion matrix (USD/GBP/EUR/JPY/KRW).""" try: - rates: Dict[str, float] = {} - for pair in MAJOR_PAIRS: - rate = _fetch_fx_rate(pair) - if rate is not None: - rates[pair] = round(rate, 4) - return FXRateResponse(pair="MAJOR", rates=rates) + 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 + + 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, + } + return FXRateResponse(pair="MATRIX", rates=rates) except Exception as exc: raise HTTPException(status_code=500, detail=f"FX rates failed: {exc}") from exc diff --git a/atlas-terminal/server/routers/market_data.py b/atlas-terminal/server/routers/market_data.py index 977b01f..0fd3b94 100644 --- a/atlas-terminal/server/routers/market_data.py +++ b/atlas-terminal/server/routers/market_data.py @@ -3,6 +3,7 @@ from typing import Any, Dict, List from fastapi import APIRouter, Query +from server.utils.ticker_utils import AssetType, detect_asset_type router = APIRouter() @@ -51,12 +52,92 @@ async def market_indices(): return [] +@router.get("/overview", summary="Global market overview") +async def market_overview(): + try: + from server.services.market_overview import get_market_overview + + return await get_market_overview() + except Exception as e: + return {"error": str(e), "data": None} + + +@router.get("/sectors", summary="Sector heatmap") +async def sector_heatmap(): + try: + from server.services.sector_heatmap import get_sector_heatmap + + return await get_sector_heatmap() + except Exception as e: + return {"error": str(e), "data": None} + + +@router.get("/overview/{ticker}", summary="Asset-type aware overview") +async def market_overview_by_ticker(ticker: str): + """Detect asset type and return overview payload for that type.""" + try: + asset_type = detect_asset_type(ticker) + if asset_type == AssetType.ETF: + from server.services.etf_analysis import get_etf_overview + + return {"asset_type": AssetType.ETF.value, "data": await get_etf_overview(ticker)} + if asset_type == AssetType.COMMODITY_FUTURE: + from server.services.commodity_analysis import get_commodity_overview + + return {"asset_type": AssetType.COMMODITY_FUTURE.value, "data": await get_commodity_overview(ticker)} + if asset_type == AssetType.CRYPTO: + return {"asset_type": AssetType.CRYPTO.value, "data": {"name": ticker.upper()}} + if asset_type == AssetType.INDEX: + return {"asset_type": AssetType.INDEX.value, "data": {"name": ticker.upper()}} + from server.services.etf_analysis import get_equity_overview + + return {"asset_type": AssetType.EQUITY.value, "data": await get_equity_overview(ticker)} + except Exception as e: + return {"error": str(e), "asset_type": AssetType.EQUITY.value, "data": None} + + +@router.get("/etf/{ticker}/holdings", summary="ETF top holdings") +async def etf_holdings(ticker: str): + try: + from server.services.etf_analysis import get_etf_holdings + + return {"ticker": ticker.upper(), "holdings": await get_etf_holdings(ticker)} + except Exception as e: + return {"error": str(e), "ticker": ticker.upper(), "holdings": []} + + +@router.get("/commodity/{ticker}/seasonal", summary="Commodity monthly seasonal pattern") +async def commodity_seasonal(ticker: str): + try: + from server.services.commodity_analysis import get_commodity_overview + + data = await get_commodity_overview(ticker) + return {"ticker": ticker.upper(), "seasonal_pattern": data.get("seasonal_pattern", {})} + except Exception as e: + return {"error": str(e), "ticker": ticker.upper(), "seasonal_pattern": {}} + + +@router.get("/commodity/{ticker}/correlations", summary="Commodity correlations") +async def commodity_correlations(ticker: str): + try: + from server.services.commodity_analysis import compute_commodity_correlations + + return {"ticker": ticker.upper(), "correlations": await compute_commodity_correlations(ticker)} + except Exception as e: + return {"error": str(e), "ticker": ticker.upper(), "correlations": {}} + + @router.get("/sector/{ticker}", summary="Sector and industry classification") async def sector_industry(ticker: str): try: import yfinance as yf t = yf.Ticker(ticker.upper()) info = t.info or {} + city = (info.get("city") or "").strip() + state = (info.get("state") or "").strip() + country = (info.get("country") or "").strip() + hq_parts = [p for p in [city, state, country] if p] + hq = ", ".join(hq_parts) if hq_parts else "N/A" return { "sector": info.get("sector", "N/A"), "industry": info.get("industry", "N/A"), @@ -67,6 +148,13 @@ async def sector_industry(ticker: str): "fifty_two_week_high": _safe_float(info.get("fiftyTwoWeekHigh")), "fifty_two_week_low": _safe_float(info.get("fiftyTwoWeekLow")), "current_price": _safe_float(info.get("currentPrice") or info.get("regularMarketPrice")), + "ceo": info.get("companyOfficers", [{}])[0].get("name") if isinstance(info.get("companyOfficers"), list) and info.get("companyOfficers") else None, + "employees": info.get("fullTimeEmployees"), + "founded": info.get("founded"), + "hq": hq, + "website": info.get("website"), + "ipo_date": info.get("ipoExpectedDate") or info.get("firstTradeDateEpochUtc"), + "description": info.get("longBusinessSummary"), } except Exception: return {"sector": "N/A", "industry": "N/A"} @@ -134,7 +222,15 @@ async def industry_comps(tickers: str = Query(..., description="Comma-separated @router.get("/health/{ticker}", summary="DuPont, Altman Z-Score, Red Flags") async def financial_health(ticker: str): - fallback = {"ticker": ticker.upper(), "dupont": {}, "altman_z": None, "red_flags": []} + fallback = { + "ticker": ticker.upper(), + "dupont": {}, + "altman_z": None, + "current_ratio": None, + "interest_coverage": None, + "debt_to_equity": None, + "red_flags": [], + } try: import yfinance as yf t = yf.Ticker(ticker.upper()) @@ -199,20 +295,61 @@ async def financial_health(ticker: str): rev_ta = rev / ta altman_z = round(1.2 * wc_ta + 1.4 * re_ta + 3.3 * ebit_ta + 0.6 * mc_tl + 1.0 * rev_ta, 2) + # Additional health metrics for overview cards + current_ratio = None + if bs is not None and not bs.empty: + col_bs = bs.columns[0] + ca = _safe_float(bs.loc["Current Assets"][col_bs]) if "Current Assets" in bs.index else 0 + cl = _safe_float(bs.loc["Current Liabilities"][col_bs]) if "Current Liabilities" in bs.index else 0 + current_ratio = (ca / cl) if cl else None + if current_ratio is None: + info_cr = _safe_float(info.get("currentRatio"), None) + current_ratio = info_cr if info_cr and info_cr > 0 else None + + interest_coverage = None + if fin is not None and not fin.empty: + col_fin = fin.columns[0] + ebit = _safe_float(fin.loc["EBIT"][col_fin]) if "EBIT" in fin.index else _safe_float(fin.loc["Operating Income"][col_fin]) if "Operating Income" in fin.index else None + int_exp = _safe_float(fin.loc["Interest Expense"][col_fin]) if "Interest Expense" in fin.index else None + if ebit is not None and int_exp is not None and int_exp != 0: + interest_coverage = abs(ebit / int_exp) + + debt_to_equity = None + if bs is not None and not bs.empty: + col_bs = bs.columns[0] + total_debt = _safe_float(bs.loc["Total Debt"][col_bs], None) if "Total Debt" in bs.index else None + if total_debt is None: + ltd = _safe_float(bs.loc["Long Term Debt"][col_bs], 0) if "Long Term Debt" in bs.index else 0 + std = _safe_float(bs.loc["Current Debt"][col_bs], 0) if "Current Debt" in bs.index else 0 + total_debt = ltd + std if (ltd or std) else None + equity = total_equity if total_equity else None + if total_debt is not None and equity: + debt_to_equity = total_debt / equity + if debt_to_equity is None: + de_info = _safe_float(info.get("debtToEquity"), None) + if de_info is not None: + debt_to_equity = de_info / 100 if de_info > 10 else de_info + # Red Flags red_flags = [] - cr = _safe_float(info.get("currentRatio")) - de = _safe_float(info.get("debtToEquity")) - if cr and cr < 1.0: - red_flags.append(f"Low current ratio: {cr:.2f}") - if de and de > 200: - red_flags.append(f"High debt-to-equity: {de:.1f}%") + if current_ratio is not None and current_ratio < 1.0: + red_flags.append(f"Low current ratio: {current_ratio:.2f}") + if debt_to_equity is not None and debt_to_equity > 2.0: + red_flags.append(f"High debt-to-equity: {debt_to_equity:.2f}") if npm and npm < 0: red_flags.append("Negative profit margin") if roe and roe < 0: red_flags.append("Negative ROE") - return {"ticker": ticker.upper(), "dupont": dupont, "altman_z": altman_z, "red_flags": red_flags} + return { + "ticker": ticker.upper(), + "dupont": dupont, + "altman_z": altman_z, + "current_ratio": round(current_ratio, 2) if current_ratio is not None else None, + "interest_coverage": round(interest_coverage, 2) if interest_coverage is not None else None, + "debt_to_equity": round(debt_to_equity, 2) if debt_to_equity is not None else None, + "red_flags": red_flags, + } except Exception as e: return fallback diff --git a/atlas-terminal/server/routers/markets.py b/atlas-terminal/server/routers/markets.py new file mode 100644 index 0000000..283a3d3 --- /dev/null +++ b/atlas-terminal/server/routers/markets.py @@ -0,0 +1,14 @@ +"""Markets router for index constituent heatmap.""" + +from fastapi import APIRouter, Query + +router = APIRouter() + + +@router.get("/heatmap/{index_name}", summary="Index constituent heatmap data") +async def heatmap(index_name: str, top_n: int = Query(default=50, ge=1, le=200)): + from server.services.heatmap import get_heatmap_data + + stocks = await get_heatmap_data(index_name, top_n) + return {"index": index_name, "count": len(stocks), "stocks": stocks} + diff --git a/atlas-terminal/server/routers/portfolio.py b/atlas-terminal/server/routers/portfolio.py index 755f386..2572300 100644 --- a/atlas-terminal/server/routers/portfolio.py +++ b/atlas-terminal/server/routers/portfolio.py @@ -1,11 +1,13 @@ """Portfolio router -- position management, OCR screenshot upload, summary.""" import json +import os import uuid from pathlib import Path from typing import List -from fastapi import APIRouter, HTTPException, UploadFile, File +from fastapi import APIRouter, HTTPException, UploadFile, File, Header +from pydantic import BaseModel, Field from server.models.schemas import ( PortfolioPosition, @@ -19,6 +21,18 @@ router = APIRouter() _PORTFOLIO_FILE = Path(__file__).resolve().parent.parent.parent / "data" / "portfolio.json" +class PositionUpdateRequest(BaseModel): + quantity: float = Field(..., gt=0) + avg_price: float = Field(..., ge=0) + exchange: str = "" + + +class OcrRecalculateRequest(BaseModel): + account_currency: str = "USD" + position: dict + selected_exchange: str = "" + + def _load_positions() -> List[dict]: """Load positions from the JSON store.""" if not _PORTFOLIO_FILE.exists(): @@ -77,24 +91,36 @@ async def list_positions(): @router.post( "/positions", response_model=PortfolioPosition, - summary="Add a portfolio position", + summary="Add or update a portfolio position", ) async def add_position(pos: PortfolioPositionCreate): - """Add a new position to the portfolio.""" + """Add a new position. If ticker exists, update quantity/avg/currency.""" try: positions = _load_positions() - new_pos = { - "id": str(uuid.uuid4()), - "ticker": pos.ticker.upper(), - "company_name": pos.company_name, - "quantity": pos.quantity, - "avg_price": pos.avg_price, - "currency": pos.currency, - "source": pos.source, - } - positions.append(new_pos) + ticker = pos.ticker.upper() + existing = next((p for p in positions if str(p.get("ticker", "")).upper() == ticker), None) + if existing is not None: + existing["company_name"] = pos.company_name or existing.get("company_name", "") + existing["quantity"] = float(pos.quantity) + existing["avg_price"] = float(pos.avg_price) + existing["currency"] = pos.currency or existing.get("currency", "USD") + existing["exchange"] = pos.exchange or existing.get("exchange", "") + existing["source"] = pos.source or existing.get("source", "manual") + saved = existing + else: + saved = { + "id": str(uuid.uuid4()), + "ticker": ticker, + "company_name": pos.company_name, + "quantity": pos.quantity, + "avg_price": pos.avg_price, + "currency": pos.currency, + "exchange": pos.exchange, + "source": pos.source, + } + positions.append(saved) _save_positions(positions) - return PortfolioPosition(**new_pos) + return PortfolioPosition(**saved) except Exception as exc: raise HTTPException(status_code=500, detail=f"Failed to add position: {exc}") from exc @@ -121,6 +147,79 @@ async def remove_position(position_id: str): raise HTTPException(status_code=500, detail=f"Failed to remove position: {exc}") from exc +@router.put( + "/positions/{position_id}", + response_model=PortfolioPosition, + summary="Update quantity/avg price for a position", +) +async def update_position(position_id: str, body: PositionUpdateRequest): + """Update an existing position by unique ID.""" + try: + positions = _load_positions() + updated = None + for p in positions: + if p.get("id") == position_id: + p["quantity"] = float(body.quantity) + p["avg_price"] = float(body.avg_price) + if body.exchange: + p["exchange"] = body.exchange + updated = p + break + if updated is None: + raise HTTPException(status_code=404, detail=f"Position {position_id} not found.") + _save_positions(positions) + return PortfolioPosition(**updated) + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Failed to update position: {exc}") from exc + + +@router.post( + "/ocr", + summary="OCR screenshot with smart reverse-engineering", +) +async def ocr_screenshot( + file: UploadFile = File(...), + x_gemini_api_key: str | None = Header(default=None), +): + """Screenshot -> OCR extraction -> market-validated reverse-engineered positions.""" + try: + from server.services.screenshot_ocr import process_portfolio_screenshot + + image_bytes = await file.read() + api_key = (x_gemini_api_key or "").strip() or os.getenv("GOOGLE_API_KEY", "").strip() + result = await process_portfolio_screenshot(api_key, image_bytes) + if result.get("error"): + return {"error": result.get("error"), "positions": [], "count": 0, "warnings": []} + result["count"] = len(result.get("positions") or []) + return result + except Exception as exc: + raise HTTPException(status_code=500, detail=f"OCR processing failed: {exc}") from exc + + +@router.get("/exchange-options/{ticker}", summary="Available exchange options for a ticker") +async def exchange_options(ticker: str): + from server.services.exchange_resolver import get_exchange_options + + return {"ticker": ticker.upper(), "options": get_exchange_options(ticker)} + + +@router.post("/ocr/recalculate", summary="Recalculate one OCR row with selected exchange") +async def ocr_recalculate(body: OcrRecalculateRequest): + from server.services.screenshot_ocr import reverse_engineer_positions + + ticker = str((body.position or {}).get("ticker", "")).upper() + if not ticker: + raise HTTPException(status_code=400, detail="position.ticker is required") + payload = {"account_currency": body.account_currency, "positions": [body.position]} + overrides = {ticker: body.selected_exchange} if body.selected_exchange else {} + recalculated = reverse_engineer_positions(payload, overrides) + if not recalculated: + raise HTTPException(status_code=400, detail="Failed to recalculate position") + return {"position": recalculated[0]} + + @router.post( "/screenshot", summary="Upload screenshot for OCR analysis", @@ -209,6 +308,7 @@ async def portfolio_summary(): quantity=quantity, avg_price=avg_price, currency=p.get("currency", "USD"), + exchange=p.get("exchange", ""), source=p.get("source", "manual"), current_price=current_price, market_value=market_value, diff --git a/atlas-terminal/server/routers/screener.py b/atlas-terminal/server/routers/screener.py new file mode 100644 index 0000000..c631f9a --- /dev/null +++ b/atlas-terminal/server/routers/screener.py @@ -0,0 +1,34 @@ +"""Screener and backtesting router.""" + +from __future__ import annotations + +from fastapi import APIRouter + +router = APIRouter() + + +@router.post("/search") +async def search_stocks(filters: dict): + """Run stock screener with simple filters.""" + try: + from server.services.screener import run_screener + + return await run_screener(filters) + except Exception as e: + return {"error": str(e), "data": []} + + +@router.post("/backtest") +async def backtest(body: dict): + """Run strategy backtest for one ticker.""" + try: + from server.services.backtester import run_backtest + + return await run_backtest( + ticker=body.get("ticker", ""), + strategy=body.get("strategy", "buy_and_hold"), + start_date=body.get("start_date", "2024-01-01"), + end_date=body.get("end_date", "2026-01-01"), + ) + except Exception as e: + return {"error": str(e)} diff --git a/atlas-terminal/server/services/backtester.py b/atlas-terminal/server/services/backtester.py new file mode 100644 index 0000000..cf2c24e --- /dev/null +++ b/atlas-terminal/server/services/backtester.py @@ -0,0 +1,47 @@ +"""Backtesting service for simple strategies.""" + +from __future__ import annotations + + +async def run_backtest( + ticker: str, + strategy: str, + start_date: str, + end_date: str, + initial_capital: float = 10000.0, +) -> dict: + """Run a basic backtest for selected strategy.""" + import yfinance as yf + import ta + + df = yf.Ticker(ticker.upper()).history(start=start_date, end=end_date) + if df is None or df.empty: + return {"error": "No price data"} + + if strategy == "sma_crossover": + df["sma50"] = ta.trend.sma_indicator(df["Close"], 50) + df["sma200"] = ta.trend.sma_indicator(df["Close"], 200) + df["signal"] = (df["sma50"] > df["sma200"]).astype(int) + elif strategy == "rsi_oversold": + df["rsi"] = ta.momentum.rsi(df["Close"], 14) + df["signal"] = 0 + df.loc[df["rsi"] < 30, "signal"] = 1 + df.loc[df["rsi"] > 70, "signal"] = 0 + else: + df["signal"] = 1 + + df["returns"] = df["Close"].pct_change().fillna(0) + df["strategy_returns"] = (df["returns"] * df["signal"].shift(1)).fillna(0) + cumulative = (1 + df["strategy_returns"]).cumprod() + benchmark = (1 + df["returns"]).cumprod() + + return { + "total_return_pct": round((float(cumulative.iloc[-1]) - 1) * 100, 2), + "benchmark_return_pct": round((float(benchmark.iloc[-1]) - 1) * 100, 2), + "alpha": round((float(cumulative.iloc[-1]) - float(benchmark.iloc[-1])) * 100, 2), + "max_drawdown_pct": round(float(((cumulative / cumulative.cummax()) - 1).min()) * 100, 2), + "sharpe_ratio": round(float(df["strategy_returns"].mean() / (df["strategy_returns"].std() + 1e-10) * (252 ** 0.5)), 2), + "equity_curve": [float(x) for x in cumulative.tolist()], + "benchmark_curve": [float(x) for x in benchmark.tolist()], + "dates": df.index.strftime("%Y-%m-%d").tolist(), + } diff --git a/atlas-terminal/server/services/commodity_analysis.py b/atlas-terminal/server/services/commodity_analysis.py new file mode 100644 index 0000000..f734860 --- /dev/null +++ b/atlas-terminal/server/services/commodity_analysis.py @@ -0,0 +1,99 @@ +"""Commodity future analysis helpers.""" + +from __future__ import annotations + +from typing import Any + +from server.utils.ticker_utils import COMMODITY_FUTURES + +COMMODITY_RELATED: dict[str, list[str]] = { + "GC=F": ["GLD", "SI=F", "DX-Y.NYB", "^TNX"], + "CL=F": ["USO", "BZ=F", "XLE", "^GSPC"], + "SI=F": ["SLV", "GC=F", "HG=F", "^GSPC"], + "NG=F": ["UNG", "CL=F", "XLE"], +} + + +def _get_related_assets(ticker: str) -> list[str]: + return COMMODITY_RELATED.get(ticker.upper(), []) + + +async def compute_commodity_correlations(ticker: str, period: str = "1y") -> dict: + import yfinance as yf + + t = ticker.upper() + related = _get_related_assets(t) + if not related: + return {} + all_tickers = [t] + related + data = yf.download(all_tickers, period=period, auto_adjust=True, progress=False) + if data is None or data.empty: + return {} + close = data["Close"] if "Close" in data else data + returns = close.pct_change().dropna() + if returns is None or returns.empty or t not in returns.columns: + return {} + corr = returns.corr() + result = {} + for r in related: + if r in corr.columns: + result[r] = round(float(corr.loc[t, r]), 2) + return result + + +async def get_commodity_overview(ticker: str) -> dict: + import yfinance as yf + + t = ticker.upper() + y = yf.Ticker(t) + info = y.info or {} + hist_1y = y.history(period="1y", auto_adjust=True) + hist_10y = y.history(period="10y", auto_adjust=True) + + seasonal = {} + if hist_10y is not None and not hist_10y.empty: + monthly = hist_10y["Close"].resample("ME").last().pct_change().dropna() + for month in range(1, 13): + m = monthly[monthly.index.month == month] + seasonal[month] = round(float(m.mean()) * 100, 2) if len(m) > 0 else 0 + + related = _get_related_assets(t) + related_cards = [] + if related: + data = yf.download(related, period="5d", auto_adjust=True, progress=False) + close = data["Close"] if hasattr(data, "columns") and "Close" in data.columns else data + if close is not None: + try: + if hasattr(close, "columns"): + for sym in related: + if sym not in close.columns: + continue + s = close[sym].dropna() + if len(s) < 1: + continue + cur = float(s.iloc[-1]) + prev = float(s.iloc[-2]) if len(s) > 1 else cur + pct = ((cur - prev) / prev * 100) if prev else 0 + related_cards.append({"symbol": sym, "price": round(cur, 2), "change_pct": round(pct, 2)}) + else: + s = close.dropna() + if len(s) >= 1: + cur = float(s.iloc[-1]) + prev = float(s.iloc[-2]) if len(s) > 1 else cur + pct = ((cur - prev) / prev * 100) if prev else 0 + related_cards.append({"symbol": related[0], "price": round(cur, 2), "change_pct": round(pct, 2)}) + except Exception: + pass + + return { + "name": COMMODITY_FUTURES.get(t, info.get("shortName", t)), + "price": info.get("regularMarketPrice") or info.get("currentPrice"), + "open_interest": info.get("openInterest"), + "volume": info.get("volume"), + "high_52w": info.get("fiftyTwoWeekHigh"), + "low_52w": info.get("fiftyTwoWeekLow"), + "seasonal_pattern": seasonal, + "related_assets": related_cards, + "correlation_matrix": await compute_commodity_correlations(t), + "asset_class": "commodity_future", + } diff --git a/atlas-terminal/server/services/copilot_context.py b/atlas-terminal/server/services/copilot_context.py new file mode 100644 index 0000000..eb3923b --- /dev/null +++ b/atlas-terminal/server/services/copilot_context.py @@ -0,0 +1,24 @@ +"""Build compact copilot context with asset-type aware fields.""" + +from __future__ import annotations + + +def build_copilot_context(asset_type: str, data: dict) -> str: + parts: list[str] = [] + if asset_type == "etf": + parts.append(f"[Asset Type] ETF — {data.get('category')}") + parts.append(f"[ETF] AUM: {data.get('aum')}, Expense: {data.get('expense_ratio')}") + r = data.get("returns") or {} + parts.append(f"[Performance] YTD: {r.get('ytd')}%, 1Y: {r.get('1y')}%") + elif asset_type == "commodity_future": + parts.append(f"[Asset Type] Commodity Future — {data.get('name')}") + parts.append(f"[Commodity] Open Interest: {data.get('open_interest')}") + seasonal = data.get("seasonal_pattern") or {} + if seasonal: + best_month = max(seasonal, key=lambda k: seasonal[k]) + worst_month = min(seasonal, key=lambda k: seasonal[k]) + parts.append(f"[Seasonal] Best month: {best_month}, Worst: {worst_month}") + else: + parts.append("[Asset Type] Equity") + parts.append(f"[Sector] {data.get('sector')}") + return "\n".join(parts) diff --git a/atlas-terminal/server/services/etf_analysis.py b/atlas-terminal/server/services/etf_analysis.py new file mode 100644 index 0000000..fbba949 --- /dev/null +++ b/atlas-terminal/server/services/etf_analysis.py @@ -0,0 +1,162 @@ +"""ETF and equity-like overview helpers.""" + +from __future__ import annotations + +import math +from typing import Any + + +def _safe_num(v: Any) -> float | None: + try: + f = float(v) + if math.isnan(f) or math.isinf(f): + return None + return f + except Exception: + return None + + +def _compute_sharpe(returns) -> float | None: + if returns is None or len(returns) < 2: + return None + std = returns.std() + if not std: + return None + return round(float((returns.mean() / std) * (252**0.5)), 2) + + +def _compute_sortino(returns) -> float | None: + if returns is None or len(returns) < 2: + return None + downside = returns[returns < 0] + if downside is None or len(downside) < 2: + return None + std = downside.std() + if not std: + return None + return round(float((returns.mean() / std) * (252**0.5)), 2) + + +def _max_drawdown(returns) -> float | None: + if returns is None or len(returns) < 2: + return None + curve = (1 + returns).cumprod() + dd = (curve / curve.cummax()) - 1 + return round(float(dd.min()) * 100, 2) + + +async def get_benchmark_comparison(ticker: str, benchmark: str = "SPY", period: str = "1y") -> dict: + import yfinance as yf + + data = yf.download([ticker.upper(), benchmark.upper()], period=period, auto_adjust=True, progress=False) + if data is None or data.empty: + return {} + close = data["Close"] if "Close" in data else data + if close is None or close.empty: + return {} + t_col = ticker.upper() + b_col = benchmark.upper() + if t_col not in close.columns or b_col not in close.columns: + return {} + close = close[[t_col, b_col]].dropna() + if close.empty: + return {} + normalized = close / close.iloc[0] * 100 + return { + "dates": normalized.index.strftime("%Y-%m-%d").tolist(), + "ticker_values": [float(x) for x in normalized[t_col].tolist()], + "benchmark_values": [float(x) for x in normalized[b_col].tolist()], + "benchmark": benchmark.upper(), + } + + +async def get_etf_holdings(ticker: str, top_n: int = 10) -> list[dict]: + import yfinance as yf + + t = yf.Ticker(ticker.upper()) + out = [] + try: + holdings = getattr(t, "fund_top_holdings", None) + if holdings is not None and not holdings.empty: + for _, row in holdings.head(top_n).iterrows(): + out.append( + { + "symbol": row.get("symbol") or row.get("holdingName") or "", + "name": row.get("holdingName") or row.get("symbol") or "", + "weight_pct": _safe_num(row.get("holdingPercent")), + } + ) + except Exception: + pass + return out + + +async def get_etf_overview(ticker: str) -> dict: + import yfinance as yf + + t = yf.Ticker(ticker.upper()) + info = t.info or {} + hist = t.history(period="5y", auto_adjust=True) + + def period_return(days: int) -> float | None: + if hist is None or hist.empty or len(hist) <= days: + return None + cur = _safe_num(hist["Close"].iloc[-1]) + prev = _safe_num(hist["Close"].iloc[-days]) + if cur is None or prev is None or prev == 0: + return None + return round((cur / prev - 1) * 100, 2) + + ytd_days = 0 + if hist is not None and not hist.empty: + ytd_days = int((hist.index.year == hist.index[-1].year).sum()) + returns = hist["Close"].pct_change().dropna() if hist is not None and not hist.empty else None + + return { + "name": info.get("longName") or info.get("shortName", ticker.upper()), + "category": info.get("category") or info.get("fundFamily") or "N/A", + "aum": _safe_num(info.get("totalAssets")), + "expense_ratio": _safe_num(info.get("annualReportExpenseRatio")), + "nav": _safe_num(info.get("navPrice")), + "inception": info.get("fundInceptionDate"), + "price": _safe_num(info.get("currentPrice") or info.get("regularMarketPrice")), + "high_52w": _safe_num(info.get("fiftyTwoWeekHigh")), + "low_52w": _safe_num(info.get("fiftyTwoWeekLow")), + "returns": { + "1m": period_return(21), + "3m": period_return(63), + "6m": period_return(126), + "ytd": period_return(ytd_days) if ytd_days else None, + "1y": period_return(252), + "3y": period_return(756), + "5y": period_return(1260), + }, + "holdings": await get_etf_holdings(ticker, top_n=10), + "risk": { + "sharpe": _compute_sharpe(returns), + "sortino": _compute_sortino(returns), + "max_drawdown": _max_drawdown(returns), + "volatility": round(float(returns.std()) * (252**0.5) * 100, 2) if returns is not None and len(returns) > 1 else None, + }, + "benchmark_comparison": await get_benchmark_comparison(ticker, "SPY", "1y"), + } + + +async def get_equity_overview(ticker: str) -> dict: + import yfinance as yf + + t = yf.Ticker(ticker.upper()) + info = t.info or {} + return { + "name": info.get("longName") or info.get("shortName", ticker.upper()), + "sector": info.get("sector"), + "industry": info.get("industry"), + "market_cap": _safe_num(info.get("marketCap")), + "pe_ratio": _safe_num(info.get("trailingPE")) or _safe_num(info.get("forwardPE")), + "dividend_yield": _safe_num(info.get("dividendYield")), + "beta": _safe_num(info.get("beta")), + "high_52w": _safe_num(info.get("fiftyTwoWeekHigh")), + "low_52w": _safe_num(info.get("fiftyTwoWeekLow")), + "price": _safe_num(info.get("currentPrice") or info.get("regularMarketPrice")), + "description": info.get("longBusinessSummary"), + } diff --git a/atlas-terminal/server/services/exchange_resolver.py b/atlas-terminal/server/services/exchange_resolver.py new file mode 100644 index 0000000..68cd7d9 --- /dev/null +++ b/atlas-terminal/server/services/exchange_resolver.py @@ -0,0 +1,51 @@ +"""Resolve multi-exchange tickers for OCR/import workflows.""" + +from __future__ import annotations + +MULTI_EXCHANGE_TICKERS = { + "SMSN": [ + {"exchange": "LSE (GDR)", "yf_ticker": "SMSN.L", "currency": "USD", "default": True}, + {"exchange": "KRX (Korea)", "yf_ticker": "005930.KS", "currency": "KRW"}, + {"exchange": "OTC (US)", "yf_ticker": "SSNLF", "currency": "USD"}, + ], + "NOV": [ + {"exchange": "NYSE", "yf_ticker": "NVO", "currency": "USD", "default": True}, + {"exchange": "Copenhagen", "yf_ticker": "NOVO-B.CO", "currency": "DKK"}, + ], + "NVO": [ + {"exchange": "NYSE", "yf_ticker": "NVO", "currency": "USD", "default": True}, + {"exchange": "Copenhagen", "yf_ticker": "NOVO-B.CO", "currency": "DKK"}, + ], +} + +T212_TICKER_MAP = { + "SMSN": "SMSN.L", + "SMSN.L": "SMSN.L", + "NOV": "NVO", + "NVDA": "NVDA", + "TSLA": "TSLA", + "NVO": "NVO", + "PLTR": "PLTR", + "IONQ": "IONQ", + "IREN": "IREN", +} + + +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: + t = (ticker or "").upper().strip() + options = get_exchange_options(t) + if not options: + return T212_TICKER_MAP.get(t, t) + if selected_exchange: + for opt in options: + if opt.get("exchange") == selected_exchange: + return opt.get("yf_ticker", t) + for opt in options: + if opt.get("default"): + return opt.get("yf_ticker", t) + return options[0].get("yf_ticker", t) + diff --git a/atlas-terminal/server/services/heatmap.py b/atlas-terminal/server/services/heatmap.py new file mode 100644 index 0000000..9770454 --- /dev/null +++ b/atlas-terminal/server/services/heatmap.py @@ -0,0 +1,71 @@ +"""Heatmap data service for index constituents.""" + +from __future__ import annotations + +from typing import Any + +import pandas as pd +import yfinance as yf + + +async def get_index_constituents(index_name: str) -> list[str]: + name = (index_name or "").lower().strip() + if name == "sp500": + try: + table = pd.read_html("https://en.wikipedia.org/wiki/List_of_S%26P_500_companies")[0] + return table["Symbol"].astype(str).str.replace(".", "-", regex=False).tolist() + except Exception: + return ["AAPL", "MSFT", "NVDA", "AMZN", "GOOGL", "META", "BRK-B", "TSLA", "UNH", "XOM"] + if name == "nasdaq100": + try: + table = pd.read_html("https://en.wikipedia.org/wiki/Nasdaq-100")[4] + return table["Ticker"].astype(str).tolist() + except Exception: + return ["AAPL", "MSFT", "NVDA", "AMZN", "GOOGL", "META", "TSLA", "AVGO", "COST", "NFLX"] + if name == "kospi": + return [ + "005930.KS", "000660.KS", "035420.KS", "051910.KS", "006400.KS", + "035720.KS", "068270.KS", "028260.KS", "105560.KS", "012330.KS", + "055550.KS", "034730.KS", "003550.KS", "015760.KS", "066570.KS", + "032830.KS", "096770.KS", "009150.KS", "003670.KS", "018260.KS", + ] + if name == "ftse100": + return ["SHEL.L", "AZN.L", "HSBA.L", "ULVR.L", "BP.L", "GSK.L", "RIO.L", "LSEG.L"] + return [] + + +def _calc_change_pct(ticker: str) -> float: + try: + hist = yf.Ticker(ticker).history(period="2d") + if hist is not None and len(hist) >= 2: + prev = float(hist["Close"].iloc[-2]) + cur = float(hist["Close"].iloc[-1]) + if prev != 0: + return round((cur - prev) / prev * 100, 2) + except Exception: + pass + return 0.0 + + +async def get_heatmap_data(index_name: str, top_n: int = 50) -> list[dict[str, Any]]: + tickers = (await get_index_constituents(index_name))[: max(top_n, 1)] + out: list[dict[str, Any]] = [] + for ticker in tickers: + try: + info = yf.Ticker(ticker).info or {} + mcap = info.get("marketCap") + if not mcap or float(mcap) <= 0: + continue + out.append( + { + "ticker": ticker.replace(".KS", "").replace(".L", ""), + "name": info.get("shortName") or info.get("longName") or ticker, + "sector": info.get("sector") or "Other", + "market_cap": float(mcap), + "change_pct": _calc_change_pct(ticker), + } + ) + except Exception: + continue + return sorted(out, key=lambda x: x["market_cap"], reverse=True) + diff --git a/atlas-terminal/server/services/market_overview.py b/atlas-terminal/server/services/market_overview.py new file mode 100644 index 0000000..506d617 --- /dev/null +++ b/atlas-terminal/server/services/market_overview.py @@ -0,0 +1,57 @@ +"""Market overview service: indices, commodities, bonds, crypto, FX.""" + +from __future__ import annotations + + +INDICES = { + "S&P 500": "^GSPC", + "NASDAQ": "^IXIC", + "Dow Jones": "^DJI", + "KOSPI": "^KS11", + "Nikkei 225": "^N225", + "FTSE 100": "^FTSE", + "DAX": "^GDAXI", + "Hang Seng": "^HSI", +} +COMMODITIES = {"Gold": "GC=F", "Oil (WTI)": "CL=F", "Silver": "SI=F", "Nat Gas": "NG=F"} +BONDS = {"US 10Y": "^TNX", "US 2Y": "^IRX"} +CRYPTO = {"Bitcoin": "BTC-USD", "Ethereum": "ETH-USD"} +FX = {"EUR/USD": "EURUSD=X", "GBP/USD": "GBPUSD=X", "USD/JPY": "USDJPY=X", "USD/KRW": "USDKRW=X"} +POPULAR_ETFS = {"SPY": "SPY", "QQQ": "QQQ", "GLD": "GLD", "TLT": "TLT", "EEM": "EEM"} + + +async def get_market_overview() -> dict: + """Fetch concise multi-asset market overview from yfinance.""" + import yfinance as yf + + results = {} + for category, tickers in [ + ("indices", INDICES), + ("commodities", COMMODITIES), + ("bonds", BONDS), + ("crypto", CRYPTO), + ("fx", FX), + ("popular_etfs", POPULAR_ETFS), + ]: + cat_data = [] + for name, symbol in tickers.items(): + try: + t = yf.Ticker(symbol) + hist = t.history(period="5d") + if hist is None or hist.empty: + continue + current = float(hist["Close"].iloc[-1]) + prev = float(hist["Close"].iloc[-2]) if len(hist) > 1 else current + change_pct = ((current - prev) / prev * 100) if prev else 0.0 + cat_data.append( + { + "name": name, + "symbol": symbol, + "price": round(current, 2), + "change_pct": round(change_pct, 2), + } + ) + except Exception: + continue + results[category] = cat_data + return results diff --git a/atlas-terminal/server/services/screener.py b/atlas-terminal/server/services/screener.py new file mode 100644 index 0000000..1d4704c --- /dev/null +++ b/atlas-terminal/server/services/screener.py @@ -0,0 +1,47 @@ +"""Stock screener service.""" + +from __future__ import annotations + + +async def run_screener(filters: dict, universe: str = "sp500") -> list[dict]: + """Run simple screening against S&P 500 universe.""" + import pandas as pd + import yfinance as yf + + try: + table = pd.read_html("https://en.wikipedia.org/wiki/List_of_S%26P_500_companies")[0] + tickers = table["Symbol"].astype(str).tolist() + except Exception: + tickers = [] + + results = [] + for ticker in tickers: + try: + info = yf.Ticker(ticker).info or {} + pe = info.get("forwardPE") + mcap = info.get("marketCap") + sector = info.get("sector") + div = info.get("dividendYield") + if filters.get("pe_max") and ((pe or 9999) > filters["pe_max"]): + continue + if filters.get("sector") and sector != filters["sector"]: + continue + if filters.get("market_cap_min") and ((mcap or 0) < filters["market_cap_min"]): + continue + if filters.get("div_yield_min") and (((div or 0) * 100) < filters["div_yield_min"]): + continue + results.append( + { + "ticker": ticker, + "name": info.get("shortName", ""), + "sector": sector or "", + "market_cap": mcap, + "pe": pe, + "div_yield": (div * 100) if div is not None else None, + "price": info.get("currentPrice") or info.get("regularMarketPrice"), + "change_pct": info.get("regularMarketChangePercent"), + } + ) + except Exception: + continue + return results diff --git a/atlas-terminal/server/services/screenshot_ocr.py b/atlas-terminal/server/services/screenshot_ocr.py index f98c7ae..51a9b64 100644 --- a/atlas-terminal/server/services/screenshot_ocr.py +++ b/atlas-terminal/server/services/screenshot_ocr.py @@ -1,145 +1,291 @@ -"""Portfolio screenshot OCR using Gemini Vision. +"""Portfolio OCR with smart reverse-engineering against live market prices.""" -Analyses screenshots from Trading 212 or Interactive Brokers (IBKR) portfolio -views and extracts structured position data (ticker, quantity, market value, -gain/loss) via the Gemini multimodal API. -""" +from __future__ import annotations +import asyncio import json import re -from typing import Any, Dict, List, Optional +from typing import Any, Optional +import yfinance as yf +from server.services.exchange_resolver import resolve_ticker_with_exchange -def _get_vision_model(api_key: str) -> Any: - """Configure Gemini and return a multimodal model.""" - import google.generativeai as genai - genai.configure(api_key=api_key) - return genai.GenerativeModel("gemini-2.0-flash") +SCREENSHOT_OCR_PROMPT = """ +Analyze this screenshot of a stock trading app portfolio (Trading 212, IBKR, Webull, etc). +CRITICAL INSTRUCTIONS: +- Extract ALL positions visible in the image. There are likely 5-15 positions. +- Do NOT stop after the first position. Keep going until every position is captured. +- You MUST extract ALL positions visible in the screenshot. +- If you see 8 positions in the image, you MUST return exactly 8 objects in the positions array. +- The account currency shown at the top (£, $, €) may differ from individual stock currencies. -def _build_prompt() -> str: - """Return the extraction prompt for portfolio screenshots.""" - return """You are a financial data extraction assistant. +For EACH position, extract: +1. ticker: Stock ticker symbol exactly as shown (e.g., "IREN", "NVDA", "SMSN") +2. name: Company name +3. displayed_value: The monetary value shown (number only, no currency symbol) +4. displayed_currency: Currency symbol next to the value (£, $, €, ₩, ¥) +5. weight_pct: Portfolio weight % if shown (e.g., 28.66) +6. gain_loss_pct: P&L percentage if shown (e.g., -16.27 or +8.80) +7. gain_loss_amount: P&L monetary amount (number only) +8. shares: Number of shares if visible (preserve ALL decimals) +9. avg_price: Average purchase price if visible (number only) +10. avg_price_currency: Currency of avg price -Analyse this portfolio screenshot from a brokerage app (Trading 212, -Interactive Brokers, or similar). +ALSO extract portfolio summary from the top of the screen: +- total_value: Total portfolio value (number only) +- total_currency: Currency symbol (£, $, €) +- cost_basis: Cost basis if shown (number only) +- unrealised_pnl: Unrealised P&L (number only) +- unrealised_pnl_pct: P&L percentage -Extract every visible position and return ONLY a valid JSON object with -this structure: - -{ - "broker": "Trading 212" | "IBKR" | "Unknown", - "currency": "USD" | "GBP" | "EUR" | ..., - "positions": [ - { - "ticker": "AAPL", - "name": "Apple Inc.", - "quantity": 10.5, - "avg_price": 150.00, - "current_price": 175.00, - "market_value": 1837.50, - "gain_loss": 262.50, - "gain_loss_pct": 16.67 - } - ], - "total_value": 50000.00, - "total_gain_loss": 5000.00 -} - -Rules: -- Use null for any field you cannot read. -- quantity may be fractional (e.g. 0.125 shares). -- Monetary values should be plain numbers, no currency symbols. -- If the screenshot is not a portfolio view, return {"error": "Not a portfolio screenshot"}. -- Output ONLY the JSON object, nothing else. +Return ONLY valid JSON, no other text. """ +def _norm_currency(sym: str | None, default: str = "USD") -> str: + s = (sym or "").strip().upper() + mapping = {"£": "GBP", "$": "USD", "€": "EUR", "₩": "KRW", "¥": "JPY"} + return mapping.get(s, s or default) -def analyze_portfolio_screenshot( - api_key: str, - image_bytes: bytes, -) -> Dict[str, Any]: - """Extract portfolio positions from a brokerage screenshot. - Uses Gemini Vision (multimodal) to read the image and return - structured position data. +def _resolve_ticker(t212_ticker: str) -> str: + return resolve_ticker_with_exchange(t212_ticker, None) - Parameters - ---------- - api_key: - Google Gemini API key. - image_bytes: - Raw bytes of the screenshot image (PNG, JPEG, etc.). - Returns - ------- - dict - Parsed portfolio data with ``broker``, ``currency``, - ``positions`` (list), ``total_value``, and ``total_gain_loss``. - On error, returns ``{"error": ""}``. - """ +def _get_realtime_price(ticker: str) -> Optional[dict]: + try: + yf_ticker = _resolve_ticker(ticker) + t = yf.Ticker(yf_ticker) + info = t.info or {} + price = info.get("currentPrice") or info.get("regularMarketPrice") or info.get("previousClose") + currency = (info.get("currency") or "USD").upper() + if price is None: + fast = getattr(t, "fast_info", None) + if fast: + price = getattr(fast, "last_price", None) + if price is None: + hist = t.history(period="1d") + if hist is not None and not hist.empty: + price = float(hist["Close"].iloc[-1]) + if price is None: + return None + return {"price": float(price), "currency": currency, "yf_ticker": yf_ticker} + except Exception: + return None + + +def _get_fx_rate(from_currency: str, to_currency: str) -> float: + f = _norm_currency(from_currency) + t = _norm_currency(to_currency) + if f == t: + return 1.0 + try: + pair = f"{f}{t}=X" + hist = yf.Ticker(pair).history(period="1d") + if hist is not None and not hist.empty: + return float(hist["Close"].iloc[-1]) + rev = f"{t}{f}=X" + hist2 = yf.Ticker(rev).history(period="1d") + if hist2 is not None and not hist2.empty: + return 1.0 / float(hist2["Close"].iloc[-1]) + except Exception: + pass + fallback = { + ("GBP", "USD"): 1.27, ("USD", "GBP"): 0.79, + ("EUR", "USD"): 1.08, ("USD", "EUR"): 0.93, + ("USD", "KRW"): 1370.0, ("KRW", "USD"): 0.00073, + ("USD", "JPY"): 149.5, ("JPY", "USD"): 0.0067, + } + return fallback.get((f, t), 1.0) + + +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] = [] + for pos in ocr_result.get("positions", []) or []: + ticker = (pos.get("ticker") or "").upper().strip() + if not ticker: + continue + selected_exchange = (exchange_overrides or {}).get(ticker) + yf_ticker = resolve_ticker_with_exchange(ticker, selected_exchange) + mkt = _get_realtime_price(yf_ticker) + if not mkt: + out.append({ + "ticker": ticker, + "name": pos.get("name") or ticker, + "quantity": pos.get("shares"), + "avg_price": pos.get("avg_price"), + "avg_price_currency": _norm_currency(pos.get("avg_price_currency"), "USD"), + "current_price": None, + "stock_currency": "USD", + "account_currency": account_currency, + "current_value_account": pos.get("displayed_value"), + "pnl_pct": pos.get("gain_loss_pct"), + "confidence": "low", + "method": "ocr_only", + "yf_ticker": yf_ticker, + }) + continue + + stock_price = float(mkt["price"]) + stock_currency = _norm_currency(mkt["currency"], "USD") + shares = pos.get("shares") + confidence = "high" + method = "ocr_shares" + + if not shares: + displayed_value = pos.get("displayed_value") + displayed_currency = _norm_currency(pos.get("displayed_currency"), account_currency) + if displayed_value and float(displayed_value) > 0: + v_stock = float(displayed_value) * _get_fx_rate(displayed_currency, stock_currency) + shares = v_stock / stock_price if stock_price > 0 else None + confidence = "medium" + method = "reverse_from_value" + else: + shares = None + confidence = "low" + method = "unknown" + + avg_price = pos.get("avg_price") + avg_currency = _norm_currency(pos.get("avg_price_currency"), stock_currency) + avg_price_stock = None + avg_method = "ocr_avg" + if avg_price: + avg_price_stock = float(avg_price) * _get_fx_rate(avg_currency, stock_currency) + else: + gain_loss_pct = pos.get("gain_loss_pct") + gain_loss_amount = pos.get("gain_loss_amount") + displayed_value = pos.get("displayed_value") + displayed_currency = _norm_currency(pos.get("displayed_currency"), account_currency) + + # Method 1: reverse from PnL % + try: + if gain_loss_pct is not None and stock_price is not None: + gl_pct = float(gain_loss_pct) + denom = 1 + (gl_pct / 100.0) + if abs(denom) > 1e-9: + avg_price_stock = stock_price / denom + avg_method = "reverse_from_pnl_pct" + except Exception: + avg_price_stock = None + + # Method 2: reverse from displayed value and pnl amount + if avg_price_stock is None: + try: + if gain_loss_amount is not None and displayed_value is not None and shares and float(shares) > 0: + cost_basis_display = float(displayed_value) - float(gain_loss_amount) + fx = _get_fx_rate(displayed_currency, stock_currency) + cost_basis_stock = cost_basis_display * fx + avg_price_stock = cost_basis_stock / float(shares) + avg_method = "reverse_from_pnl_amount" + except Exception: + avg_price_stock = None + + # Method 3: fallback to current price + if avg_price_stock is None: + avg_price_stock = stock_price + avg_method = "fallback_current_price" + + if shares and pos.get("displayed_value"): + displayed = float(pos["displayed_value"]) + displayed_currency = _norm_currency(pos.get("displayed_currency"), account_currency) + calc_value = float(shares) * stock_price * _get_fx_rate(stock_currency, displayed_currency) + err = abs(calc_value - displayed) / displayed * 100 if displayed > 0 else 999 + if err > 10 and stock_price > 0: + shares = displayed * _get_fx_rate(displayed_currency, stock_currency) / stock_price + confidence = "medium" + method = "reverse_recalculated" + + total_pnl = None + pnl_pct = pos.get("gain_loss_pct") + if shares and avg_price_stock and stock_price: + pnl_per_share = stock_price - avg_price_stock + total_pnl = pnl_per_share * float(shares) + pnl_pct = (pnl_per_share / avg_price_stock) * 100 if avg_price_stock > 0 else None + + cur_val = None + if shares: + cur_val = float(shares) * stock_price * _get_fx_rate(stock_currency, account_currency) + + # If avg is reconstructed and shares are available, promote confidence. + if confidence == "medium" and avg_method in {"reverse_from_pnl_pct", "reverse_from_pnl_amount"} and shares: + confidence = "high" + + out.append({ + "ticker": ticker, + "name": pos.get("name") or ticker, + "quantity": round(float(shares), 6) if shares else None, + "avg_price": round(float(avg_price_stock), 4) if avg_price_stock is not None else avg_price, + "avg_price_currency": stock_currency, + "current_price": round(stock_price, 2), + "stock_currency": stock_currency, + "account_currency": account_currency, + "current_value_account": round(cur_val, 2) if cur_val is not None else None, + "total_pnl": round(float(total_pnl), 2) if total_pnl is not None else None, + "pnl_pct": round(float(pnl_pct), 2) if pnl_pct is not None else None, + "weight_pct": pos.get("weight_pct"), + "confidence": confidence, + "method": method, + "avg_method": avg_method, + "yf_ticker": yf_ticker, + }) + return out + + +def _detect_mime(image_bytes: bytes) -> str: + if image_bytes[:3] == b"\xff\xd8\xff": + return "image/jpeg" + if image_bytes[:4] == b"RIFF": + return "image/webp" + return "image/png" + + +def _parse_llm_json(text: str) -> dict: + raw = (text or "").strip() + raw = re.sub(r"^```json\s*", "", raw, flags=re.I) + raw = re.sub(r"^```\s*", "", raw) + raw = re.sub(r"\s*```$", "", raw) + return json.loads(raw.strip()) + + +async def process_portfolio_screenshot(api_key: str, image_bytes: bytes) -> dict: if not api_key or not api_key.strip(): return {"error": "API key is required."} if not image_bytes: return {"error": "No image data provided."} - try: - model = _get_vision_model(api_key) - except Exception as e: - return {"error": f"Failed to initialise Gemini Vision: {e}"} - - prompt = _build_prompt() - - # Build multimodal content: image + text prompt try: import google.generativeai as genai - # Detect MIME type from magic bytes - mime_type = "image/png" - if image_bytes[:3] == b"\xff\xd8\xff": - mime_type = "image/jpeg" - elif image_bytes[:4] == b"\x89PNG": - mime_type = "image/png" - elif image_bytes[:4] == b"RIFF": - mime_type = "image/webp" - - image_part = {"mime_type": mime_type, "data": image_bytes} - response = model.generate_content( - [image_part, prompt], - generation_config={"temperature": 0.0, "max_output_tokens": 4096}, + genai.configure(api_key=api_key) + model = genai.GenerativeModel("gemini-2.0-flash") + image_part = {"mime_type": _detect_mime(image_bytes), "data": image_bytes} + response = await asyncio.to_thread( + model.generate_content, + [image_part, SCREENSHOT_OCR_PROMPT], + generation_config={"temperature": 0.0, "max_output_tokens": 8192}, ) - - raw = (response.text or "").strip() - if not raw: - return {"error": "Gemini returned an empty response."} - - # Strip markdown code fences if present - raw = re.sub(r"^```\s*json\s*", "", raw) - raw = re.sub(r"^```\s*", "", raw) - raw = re.sub(r"\s*```\s*$", "", raw) - raw = raw.strip() - - result: Dict[str, Any] = json.loads(raw) - - # Validate structure - if "error" in result: - return result - if "positions" not in result: - return {"error": "Response missing 'positions' key.", "raw": raw} - - # Coerce numeric fields - for pos in result.get("positions", []): - for key in ("quantity", "avg_price", "current_price", "market_value", "gain_loss", "gain_loss_pct"): - val = pos.get(key) - if val is not None: - try: - pos[key] = float(val) - except (TypeError, ValueError): - pos[key] = None - - return result - + parsed = _parse_llm_json(response.text or "") except json.JSONDecodeError: - return {"error": "Failed to parse JSON from Gemini response.", "raw": raw} + return {"error": "Failed to parse OCR result."} except Exception as e: - return {"error": f"Screenshot analysis failed: {e}"} + return {"error": f"OCR model call failed: {e}"} + + enriched = reverse_engineer_positions(parsed) + warnings = [] + for p in enriched: + if p.get("confidence") == "low": + warnings.append(f"{p.get('ticker')}: Low confidence (market verify failed)") + if p.get("method") == "reverse_recalculated": + warnings.append(f"{p.get('ticker')}: Quantity recalculated due to >10% mismatch") + + return { + "account_currency": _norm_currency(parsed.get("account_currency"), "USD"), + "total_value": { + "amount": parsed.get("total_value"), + "currency": _norm_currency(parsed.get("account_currency"), "USD"), + }, + "positions": enriched, + "warnings": warnings, + "raw_ocr": parsed, + } diff --git a/atlas-terminal/server/services/sector_heatmap.py b/atlas-terminal/server/services/sector_heatmap.py new file mode 100644 index 0000000..4ba3ad9 --- /dev/null +++ b/atlas-terminal/server/services/sector_heatmap.py @@ -0,0 +1,37 @@ +"""Sector performance heatmap service.""" + +from __future__ import annotations + + +SECTOR_ETFS = { + "Technology": "XLK", + "Healthcare": "XLV", + "Financials": "XLF", + "Consumer Disc.": "XLY", + "Industrials": "XLI", + "Energy": "XLE", + "Utilities": "XLU", + "Materials": "XLB", + "Real Estate": "XLRE", + "Comm. Services": "XLC", + "Consumer Staples": "XLP", +} + + +async def get_sector_heatmap() -> list[dict]: + """Return daily percent change for major US sector ETFs.""" + import yfinance as yf + + results = [] + for sector, etf in SECTOR_ETFS.items(): + try: + hist = yf.Ticker(etf).history(period="2d") + if hist is None or len(hist) < 2: + continue + prev = float(hist["Close"].iloc[-2]) + cur = float(hist["Close"].iloc[-1]) + change = ((cur - prev) / prev * 100) if prev else 0.0 + results.append({"sector": sector, "etf": etf, "change_pct": round(change, 2)}) + except Exception: + continue + return results diff --git a/atlas-terminal/server/utils/ticker_utils.py b/atlas-terminal/server/utils/ticker_utils.py index 4d28140..1db3549 100644 --- a/atlas-terminal/server/utils/ticker_utils.py +++ b/atlas-terminal/server/utils/ticker_utils.py @@ -5,6 +5,7 @@ Yahoo Finance-compatible identifiers with the correct market suffix, and provides the static lookup tables for companies and sectors. """ +from enum import Enum from typing import List, Tuple # --------------------------------------------------------------------------- @@ -41,6 +42,41 @@ MARKET_OPTIONS: List[str] = [ "UK (LSE)", ] + +class AssetType(str, Enum): + EQUITY = "equity" + ETF = "etf" + COMMODITY_FUTURE = "commodity_future" + CRYPTO = "crypto" + INDEX = "index" + + +COMMODITY_FUTURES: dict[str, str] = { + "GC=F": "Gold", "SI=F": "Silver", "PL=F": "Platinum", "PA=F": "Palladium", + "CL=F": "Crude Oil (WTI)", "BZ=F": "Brent Crude", "NG=F": "Natural Gas", + "HO=F": "Heating Oil", "RB=F": "Gasoline", + "ZC=F": "Corn", "ZS=F": "Soybeans", "ZW=F": "Wheat", + "KC=F": "Coffee", "CT=F": "Cotton", "SB=F": "Sugar", + "CC=F": "Cocoa", "OJ=F": "Orange Juice", + "LE=F": "Live Cattle", "HE=F": "Lean Hogs", + "HG=F": "Copper", "ALI=F": "Aluminum", +} + +POPULAR_COMMODITY_ETFS: dict[str, str] = { + "GLD": "SPDR Gold Trust", "IAU": "iShares Gold Trust", "SLV": "iShares Silver Trust", + "PPLT": "abrdn Platinum ETF", "USO": "United States Oil Fund", "UNG": "United States Natural Gas Fund", + "XLE": "Energy Select Sector SPDR", "VDE": "Vanguard Energy ETF", "DBC": "Invesco DB Commodity Tracking", + "GSG": "iShares S&P GSCI Commodity", "PDBC": "Invesco Optimum Yield Diversified Commodity", + "COM": "Direxion Auspice Broad Commodity", "DBA": "Invesco DB Agriculture Fund", + "WEAT": "Teucrium Wheat Fund", "CORN": "Teucrium Corn Fund", "SOYB": "Teucrium Soybean Fund", + "SPY": "S&P 500 ETF", "QQQ": "Nasdaq 100 ETF", "IWM": "Russell 2000 ETF", + "EEM": "Emerging Markets ETF", "VWO": "Vanguard FTSE Emerging Markets", + "TLT": "20+ Year Treasury Bond ETF", "HYG": "High Yield Corporate Bond ETF", + "LQD": "Investment Grade Corporate Bond ETF", "ARKK": "ARK Innovation ETF", + "XLK": "Technology Select Sector SPDR", "XLF": "Financial Select Sector SPDR", + "XLV": "Health Care Select Sector SPDR", +} + # --------------------------------------------------------------------------- # Sector / industry peer groups (top-down analysis) # --------------------------------------------------------------------------- @@ -120,3 +156,28 @@ def infer_market_from_ticker(ticker: str) -> str: if t.endswith(".L"): return "UK (LSE)" return "US (S&P/Dow/Nasdaq)" + + +def detect_asset_type(ticker: str) -> AssetType: + """Detect asset type by ticker pattern and quoteType fallback.""" + t = (ticker or "").strip().upper() + if not t: + return AssetType.EQUITY + if t.endswith("=F") or t in COMMODITY_FUTURES: + return AssetType.COMMODITY_FUTURE + if t.endswith("-USD") or t.endswith("-KRW"): + return AssetType.CRYPTO + if t.startswith("^"): + return AssetType.INDEX + try: + import yfinance as yf + + info = yf.Ticker(t).info or {} + quote_type = str(info.get("quoteType", "")).upper() + if quote_type in {"ETF", "MUTUALFUND"}: + return AssetType.ETF + except Exception: + pass + if t in POPULAR_COMMODITY_ETFS: + return AssetType.ETF + return AssetType.EQUITY