diff --git a/atlas-terminal/apps/web/src/app/components/app-shell.tsx b/atlas-terminal/apps/web/src/app/components/app-shell.tsx index 53ebcac..dbe5385 100644 --- a/atlas-terminal/apps/web/src/app/components/app-shell.tsx +++ b/atlas-terminal/apps/web/src/app/components/app-shell.tsx @@ -5,9 +5,10 @@ import { TickerBar } from "./ticker-bar"; import { ChatPanel } from "./chat-panel"; /** - * Sidebar는 usePathname()을 씁니다. Next App Router에서 SSR 출력과 클라이언트 첫 페인트가 - * 미묘하게 어긋나면 hydration 실패 → 전체 트리가 비거나(흰 화면) 콘솔에 recoverable 에러가 납니다. - * 서버에서는 사이드바를 그리지 않고(ssr: false) 클라이언트에서만 마운트해 그 클래스의 버그를 제거합니다. + * Sidebar uses usePathname(). In Next App Router, if SSR output and client first paint + * mismatch even slightly, hydration fails — the whole tree empties (white screen) or + * console shows recoverable errors. We skip sidebar on server (ssr: false) and mount + * only on client to eliminate this class of bugs. */ const SidebarClient = dynamic( () => import("./sidebar").then((m) => ({ default: m.Sidebar })), diff --git a/atlas-terminal/apps/web/src/app/components/chat-panel.tsx b/atlas-terminal/apps/web/src/app/components/chat-panel.tsx index 3212b70..0ce3a54 100644 --- a/atlas-terminal/apps/web/src/app/components/chat-panel.tsx +++ b/atlas-terminal/apps/web/src/app/components/chat-panel.tsx @@ -44,11 +44,11 @@ export function ChatPanel() { } else { setMessages((prev) => [ ...prev, - { role: "assistant", content: "Settings에서 Gemini API Key를 설정해주세요." }, + { role: "assistant", content: "Please set your Gemini API Key in Settings." }, ]); } } catch { - setMessages((prev) => [...prev, { role: "assistant", content: "연결 오류. 다시 시도해주세요." }]); + setMessages((prev) => [...prev, { role: "assistant", content: "Connection error. Please try again." }]); } setLoading(false); } diff --git a/atlas-terminal/apps/web/src/app/components/overview/EquityOverview.tsx b/atlas-terminal/apps/web/src/app/components/overview/EquityOverview.tsx index 3c3fa19..d39f4d8 100644 --- a/atlas-terminal/apps/web/src/app/components/overview/EquityOverview.tsx +++ b/atlas-terminal/apps/web/src/app/components/overview/EquityOverview.tsx @@ -33,7 +33,9 @@ export function EquityOverview({ ticker, sector, health }: EquityOverviewProps) { label: "Sector", value: String(sector?.sector ?? "—") }, { label: "Industry", value: String(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: "P/E (TTM)", value: sector?.pe_ratio != null ? Number(sector.pe_ratio).toFixed(1) : "—" }, + { label: "P/E (NTM)", value: sector?.forward_pe != null ? Number(sector.forward_pe).toFixed(1) : "—" }, + { label: "PEG Ratio", value: sector?.peg_ratio != null ? Number(sector.peg_ratio).toFixed(2) : "—" }, { 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)}` : "—" }, @@ -56,6 +58,7 @@ export function EquityOverview({ ticker, sector, health }: EquityOverviewProps) ))} +
@@ -88,6 +91,64 @@ export function EquityOverview({ ticker, sector, health }: EquityOverviewProps) ); } +function ConsensusGauge({ sector }: { sector: Record | null }) { + const current = sector?.current_price != null ? Number(sector.current_price) : null; + const target = sector?.target_mean_price != null ? Number(sector.target_mean_price) : null; + const low = sector?.target_low_price != null ? Number(sector.target_low_price) : null; + const high = sector?.target_high_price != null ? Number(sector.target_high_price) : null; + const rec = sector?.recommendation as string | undefined; + const count = sector?.analyst_count != null ? Number(sector.analyst_count) : null; + + if (!current || !target) return null; + + const upside = ((target - current) / current) * 100; + const upsideColor = upside >= 0 ? "text-accent-green" : "text-accent-red"; + const recLabel = rec ? rec.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) : "—"; + + // gauge position: map current price within [low, high] range + const gaugeLow = low ?? target * 0.7; + const gaugeHigh = high ?? target * 1.3; + const range = gaugeHigh - gaugeLow; + const currentPct = range > 0 ? Math.max(0, Math.min(100, ((current - gaugeLow) / range) * 100)) : 50; + const targetPct = range > 0 ? Math.max(0, Math.min(100, ((target - gaugeLow) / range) * 100)) : 50; + + return ( +
+
+

Analyst Consensus

+ {count != null && {count} analysts} +
+
+
+ Target + ${target.toFixed(2)} +
+
+ Upside + + {upside >= 0 ? "+" : ""}{upside.toFixed(1)}% + +
+
+ Rating + {recLabel} +
+
+ {/* Visual gauge bar */} +
+ {/* target marker */} +
+ {/* current price marker */} +
+
+
+ ${gaugeLow.toFixed(0)} + ${gaugeHigh.toFixed(0)} +
+
+ ); +} + function Card({ title, value }: { title: string; value: string }) { return (
diff --git a/atlas-terminal/apps/web/src/app/components/research/AnomalyChips.tsx b/atlas-terminal/apps/web/src/app/components/research/AnomalyChips.tsx index 99c1c47..59df56f 100644 --- a/atlas-terminal/apps/web/src/app/components/research/AnomalyChips.tsx +++ b/atlas-terminal/apps/web/src/app/components/research/AnomalyChips.tsx @@ -26,13 +26,13 @@ export function AnomalyChips({ async function onChipClick(a: FinancialAnomalyItem) { const apiKey = typeof window !== "undefined" ? localStorage.getItem("atlas_gemini_key") || "" : ""; if (!apiKey) { - setError("Settings에서 Gemini API 키를 저장한 뒤 다시 시도하세요."); + setError("Please save your Gemini API key in Settings, then try again."); setExplain(null); return; } const email = secEmail.trim() || (typeof window !== "undefined" ? localStorage.getItem("atlas_sec_email") || "" : ""); if (!email.trim()) { - setError("SEC 공정 이용 이메일을 입력하거나 localStorage `atlas_sec_email`을 설정하세요."); + setError("Please enter your SEC fair-use email or set `atlas_sec_email` in localStorage."); setExplain(null); return; } @@ -82,7 +82,7 @@ export function AnomalyChips({ confidence: data.confidence || "medium", }); } catch { - setError("네트워크 오류"); + setError("Network error"); } finally { setLoading(false); } @@ -98,7 +98,7 @@ export function AnomalyChips({ if (!anomalies.length) { return (
- YoY 변동이 임계값(30%)을 넘는 계정이 없습니다. + No accounts with YoY changes exceeding the 30% threshold.
); } diff --git a/atlas-terminal/apps/web/src/app/components/research/ResearchGridLayout.tsx b/atlas-terminal/apps/web/src/app/components/research/ResearchGridLayout.tsx index bce7bc8..298e681 100644 --- a/atlas-terminal/apps/web/src/app/components/research/ResearchGridLayout.tsx +++ b/atlas-terminal/apps/web/src/app/components/research/ResearchGridLayout.tsx @@ -59,7 +59,7 @@ export function ResearchGridLayout({ dashboard }: { dashboard: ResearchDashboard

- 칩을 누르면 10-K 발췌와 Gemini로 설명합니다. 숫자는 서버에서만 계산됩니다. + Click a chip to see the 10-K excerpt explained by Gemini. All numbers are computed server-side.

diff --git a/atlas-terminal/apps/web/src/app/components/research/SankeyWidget.tsx b/atlas-terminal/apps/web/src/app/components/research/SankeyWidget.tsx index 816db83..7febafb 100644 --- a/atlas-terminal/apps/web/src/app/components/research/SankeyWidget.tsx +++ b/atlas-terminal/apps/web/src/app/components/research/SankeyWidget.tsx @@ -64,10 +64,10 @@ export function SankeyWidget({ theme={nivoTheme} valueFormat={(v) => { const abs = Math.abs(v); - if (abs >= 1e9) return `$${(v / 1e9).toFixed(2)}B`; - if (abs >= 1e6) return `$${(v / 1e6).toFixed(1)}M`; - if (abs >= 1e3) return `$${(v / 1e3).toFixed(0)}K`; - return `$${v.toFixed(0)}`; + if (abs >= 1e9) return `$${(v / 1e9).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}B`; + if (abs >= 1e6) return `$${(v / 1e6).toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 })}M`; + if (abs >= 1e3) return `$${(v / 1e3).toLocaleString(undefined, { maximumFractionDigits: 0 })}K`; + return `$${v.toLocaleString()}`; }} />
diff --git a/atlas-terminal/apps/web/src/app/components/research/WaterfallWidget.tsx b/atlas-terminal/apps/web/src/app/components/research/WaterfallWidget.tsx index 737f3f1..bac1cd3 100644 --- a/atlas-terminal/apps/web/src/app/components/research/WaterfallWidget.tsx +++ b/atlas-terminal/apps/web/src/app/components/research/WaterfallWidget.tsx @@ -63,6 +63,14 @@ export function WaterfallWidget({ steps }: { steps: WaterfallStep[] }) { legend: "USD (reported units)", legendPosition: "middle", legendOffset: 32, + format: (v) => { + const n = Number(v); + const abs = Math.abs(n); + if (abs >= 1e9) return `${(n / 1e9).toLocaleString(undefined, { maximumFractionDigits: 1 })}B`; + if (abs >= 1e6) return `${(n / 1e6).toLocaleString(undefined, { maximumFractionDigits: 0 })}M`; + if (abs >= 1e3) return `${(n / 1e3).toLocaleString(undefined, { maximumFractionDigits: 0 })}K`; + return n.toLocaleString(); + }, }} axisLeft={{ tickSize: 0, @@ -70,6 +78,13 @@ export function WaterfallWidget({ steps }: { steps: WaterfallStep[] }) { }} enableGridX enableGridY={false} + valueFormat={(v) => { + const abs = Math.abs(v); + if (abs >= 1e9) return `$${(v / 1e9).toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 })}B`; + if (abs >= 1e6) return `$${(v / 1e6).toLocaleString(undefined, { maximumFractionDigits: 0 })}M`; + if (abs >= 1e3) return `$${(v / 1e3).toLocaleString(undefined, { maximumFractionDigits: 0 })}K`; + return `$${v.toLocaleString()}`; + }} labelSkipWidth={12} labelSkipHeight={12} labelTextColor="#E5E7EB" diff --git a/atlas-terminal/apps/web/src/app/earnings/page.tsx b/atlas-terminal/apps/web/src/app/earnings/page.tsx index 6c2f4fa..6da31b7 100644 --- a/atlas-terminal/apps/web/src/app/earnings/page.tsx +++ b/atlas-terminal/apps/web/src/app/earnings/page.tsx @@ -21,29 +21,43 @@ interface QuarterlyData { earnings: number | null; } +interface DeltaData { + available: boolean; + latest_quarter?: string; + prev_quarter?: string; + revenue_delta_pct?: number | null; + earnings_delta_pct?: number | null; + eps_trend?: { date: string; surprise_pct: number }[]; + ai_summary?: string | null; +} + export default function EarningsPage() { - const { ticker } = useTicker(); + const { ticker, initialized } = useTicker(); const [assetType, setAssetType] = useState("equity"); const [history, setHistory] = useState([]); const [calendar, setCalendar] = useState(null); const [quarterly, setQuarterly] = useState([]); + const [delta, setDelta] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { + if (!initialized) return; setLoading(true); Promise.all([ 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), fetch(`/api/market/overview/${ticker}`).then((r) => r.ok ? r.json() : null), - ]).then(([h, c, q, o]) => { + fetch(`/api/earnings/${ticker}/delta`).then((r) => r.ok ? r.json() : null), + ]).then(([h, c, q, o, d]) => { setHistory(h?.history || []); setCalendar(c); setQuarterly(q?.quarterly || []); setAssetType(o?.asset_type || "equity"); + setDelta(d?.available ? d : null); setLoading(false); }).catch(() => setLoading(false)); - }, [ticker]); + }, [ticker, initialized]); if (loading) return
Loading...
; @@ -54,7 +68,7 @@ export default function EarningsPage() { {ticker} Earnings
- 해당 자산 유형({assetType})에는 Earnings 데이터가 없습니다. + Earnings data is not available for this asset type ({assetType}).
); @@ -88,6 +102,46 @@ export default function EarningsPage() {
+ {/* Earnings Delta — What Changed */} + {delta && ( +
+

+ Earnings Delta — {delta.latest_quarter} vs {delta.prev_quarter} +

+
+
+
Revenue Change
+
= 0 ? "text-accent-green" : "text-accent-red"}`}> + {delta.revenue_delta_pct != null ? `${delta.revenue_delta_pct >= 0 ? "+" : ""}${delta.revenue_delta_pct}%` : "—"} +
+
+
+
Earnings Change
+
= 0 ? "text-accent-green" : "text-accent-red"}`}> + {delta.earnings_delta_pct != null ? `${delta.earnings_delta_pct >= 0 ? "+" : ""}${delta.earnings_delta_pct}%` : "—"} +
+
+
+ {delta.eps_trend && delta.eps_trend.length > 0 && ( +
+ {delta.eps_trend.map((e, i) => ( +
+ {e.date.slice(5)}{" "} + = 0 ? "text-accent-green" : "text-accent-red"}> + {e.surprise_pct >= 0 ? "+" : ""}{e.surprise_pct}% + +
+ ))} +
+ )} + {delta.ai_summary && ( +

+ {delta.ai_summary} +

+ )} +
+ )} + {/* EPS History — Beat/Miss Chart */}

EPS History — Beat/Miss

diff --git a/atlas-terminal/apps/web/src/app/error.tsx b/atlas-terminal/apps/web/src/app/error.tsx index 330d718..8349eca 100644 --- a/atlas-terminal/apps/web/src/app/error.tsx +++ b/atlas-terminal/apps/web/src/app/error.tsx @@ -15,7 +15,7 @@ export default function Error({ return (
-

화면을 그리는 중 오류가 났습니다.

+

An error occurred while rendering this page.

{error.message || "Unknown error"}

@@ -24,11 +24,11 @@ export default function Error({ onClick={() => reset()} className="px-4 py-2 rounded-lg bg-accent-green text-bg-primary font-mono text-sm hover:opacity-90" > - 다시 시도 + Retry

- 흰 화면만 보일 때는 브라우저 개발자 도구(F12) → Console 탭의 빨간 에러 메시지를 확인하거나, 터미널에서{" "} - rm -rf .next && npm run dev 로 캐시를 지우고 다시 실행해 보세요. + If you see a blank screen, check the browser developer tools (F12) → Console tab for red error messages, or run{" "} + rm -rf .next && npm run dev in your terminal to clear the cache and restart.

); diff --git a/atlas-terminal/apps/web/src/app/filings/page.tsx b/atlas-terminal/apps/web/src/app/filings/page.tsx index f764837..3bda318 100644 --- a/atlas-terminal/apps/web/src/app/filings/page.tsx +++ b/atlas-terminal/apps/web/src/app/filings/page.tsx @@ -18,11 +18,11 @@ const SECTIONS_SEC: FilingSectionTab[] = [ ]; const SECTIONS_DART: FilingSectionTab[] = [ - { key: "item1a", label: "투자위험 (II)", short: "투자위험", anchorId: "dart-item-1a" }, - { key: "item3", label: "소송 등", short: "소송", anchorId: "dart-item-3" }, - { key: "item7", label: "사업의 내용 / MD&A", short: "사업·MD&A", anchorId: "dart-item-7" }, - { key: "item8", label: "재무에 관한 사항", short: "재무", anchorId: "dart-item-8" }, - { key: "item9a", label: "내부통제", short: "내부통제", anchorId: "dart-item-9a" }, + { key: "item1a", label: "Investment Risk (II)", short: "Risk", anchorId: "dart-item-1a" }, + { key: "item3", label: "Litigation", short: "Legal", anchorId: "dart-item-3" }, + { key: "item7", label: "Business / MD&A", short: "MD&A", anchorId: "dart-item-7" }, + { key: "item8", label: "Financial Statements", short: "Financials", anchorId: "dart-item-8" }, + { key: "item9a", label: "Internal Controls", short: "Controls", anchorId: "dart-item-9a" }, ]; const SECTIONS_EDINET: FilingSectionTab[] = [ @@ -46,7 +46,7 @@ function mapApiSource(s: string | undefined): FilingJurisdiction { } export default function FilingsPage() { - const { ticker } = useTicker(); + const { ticker, initialized } = useTicker(); const viewerRef = useRef(null); const [activeSection, setActiveSection] = useState("item7"); const [sections, setSections] = useState>({}); @@ -61,6 +61,9 @@ export default function FilingsPage() { const [filingSource, setFilingSource] = useState(null); const [linkMap, setLinkMap] = useState | null>(null); const [infoMessage, setInfoMessage] = useState(""); + const [translatedText, setTranslatedText] = useState(""); + const [translating, setTranslating] = useState(false); + const [showTranslation, setShowTranslation] = useState(false); const previewJ = inferFilingJurisdiction(ticker); const activeJurisdiction = filingSource ?? previewJ; @@ -94,6 +97,9 @@ export default function FilingsPage() { item9a: data.item9a || "", }); setHtmlDoc(typeof data.html === "string" ? data.html : ""); + if (data.links && typeof data.links === "object") { + setLinkMap(data.links as Record); + } setActiveSection("item7"); setHtmlVersion((v) => v + 1); setLoaded(true); @@ -113,7 +119,7 @@ export default function FilingsPage() { const data = await res.json(); setFilingSource(mapApiSource(data.source)); if (data.configured === false) { - setInfoMessage(data.message || "DART_API_KEY가 설정되지 않았습니다."); + setInfoMessage(data.message || "DART_API_KEY is not configured."); setLoaded(false); } else { setSections({ @@ -124,13 +130,16 @@ export default function FilingsPage() { item9a: data.item9a || "", }); setHtmlDoc(typeof data.html === "string" ? data.html : ""); + if (data.links && typeof data.links === "object") { + setLinkMap(data.links as Record); + } setActiveSection("item7"); setHtmlVersion((v) => v + 1); setLoaded(true); } } else { const err = await res.json().catch(() => ({})); - setError(err.detail || "DART 공시를 불러오지 못했습니다."); + setError(err.detail || "Failed to load DART filing."); } setLoading(false); return; @@ -160,7 +169,7 @@ export default function FilingsPage() { setLoaded(true); } else { const err = await res.json().catch(() => ({})); - setError(err.detail || "EDINET 데이터를 불러오지 못했습니다."); + setError(err.detail || "Failed to load EDINET data."); } } catch { setError("Connection error. Make sure the backend server is running."); @@ -168,6 +177,41 @@ export default function FilingsPage() { setLoading(false); } + async function runTranslation() { + const content = sections[activeSection]; + if (!content) return; + const apiKey = localStorage.getItem("atlas_gemini_key") || ""; + if (!apiKey) { + setTranslatedText("Settings에서 Gemini API 키를 먼저 설정해주세요."); + setShowTranslation(true); + return; + } + setTranslating(true); + try { + const res = await fetch("/api/analysis/translate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: content.slice(0, 12000), + target_lang: "ko", + api_key: apiKey, + }), + }); + if (res.ok) { + const data = await res.json(); + setTranslatedText(data.translated_text || ""); + setShowTranslation(true); + } else { + setTranslatedText("번역에 실패했습니다."); + setShowTranslation(true); + } + } catch { + setTranslatedText("번역 중 오류가 발생했습니다."); + setShowTranslation(true); + } + setTranslating(false); + } + async function runAiSummary() { const content = sections[activeSection]; if (!content) return; @@ -213,8 +257,8 @@ export default function FilingsPage() { const intro = useMemo(() => { if (previewJ === "DART") { return { - title: "DART 사업보고서", - body: "한국 상장사 최신 사업보고서(연간)를 Open DART에서 받아 옵니다. 티커는 005930.KS 형식이어야 합니다. DART_API_KEY가 .env에 필요합니다.", + title: "DART Annual Report", + body: "Fetches the latest annual business report for Korean listed companies from Open DART. Ticker must be in 005930.KS format. Requires DART_API_KEY in .env.", }; } if (previewJ === "EDINET") { @@ -231,13 +275,22 @@ export default function FilingsPage() { const pageTitle = previewJ === "DART" - ? "DART 공시" + ? "DART Filings" : previewJ === "EDINET" ? "EDINET Filings" : "SEC Filings"; const needsEmail = previewJ === "SEC"; + // Auto-load filing for non-SEC jurisdictions (no email required) + useEffect(() => { + if (!initialized) return; + if (!loaded && !loading && previewJ !== "SEC") { + loadFiling(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ticker, initialized]); + return (

@@ -267,7 +320,7 @@ export default function FilingsPage() { : previewJ === "SEC" ? "Load 10-K Filing" : previewJ === "DART" - ? "사업보고서 불러오기" + ? "Load DART Report" : "Load EDINET filing"}

@@ -285,7 +338,7 @@ export default function FilingsPage() {
{previewJ === "SEC" ? "Downloading from SEC EDGAR... This may take 10-30 seconds for first download." - : "공시 원본을 가져오는 중입니다..."} + : "Fetching filing data..."}
)}
@@ -294,22 +347,26 @@ export default function FilingsPage() { {loaded && ( <> {linkMap && Object.keys(linkMap).length > 0 && ( -
- {infoMessage &&

{infoMessage}

} -
    - {Object.entries(linkMap).map(([k, v]) => ( -
  • - - {k}: {v} - -
  • - ))} -
+
+ {infoMessage &&

{infoMessage}

} + {Object.entries(linkMap).map(([k, v], i) => ( + + {k} + + + + + ))}
)} @@ -323,6 +380,8 @@ export default function FilingsPage() { onClick={() => { setActiveSection(s.key); setAiSummary(""); + setShowTranslation(false); + setTranslatedText(""); if (hasHtml) { viewerRef.current?.scrollToAnchor(s.anchorId); } @@ -352,14 +411,36 @@ export default function FilingsPage() {
{currentContent && ( - + <> + + + )}
)} - {hasHtml && ( + {showTranslation && translatedText && ( +
+
+ 한국어 번역 + — Gemini AI 번역 +
+
+ {translatedText} +
+
+ )} + + {!showTranslation && hasHtml && ( )} - {!hasHtml && currentContent && ( + {!showTranslation && !hasHtml && currentContent && (
Plain text (cached) — same source as AI Summary; formatted HTML viewer is optional. 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 3a2fb4d..be0ff90 100644 --- a/atlas-terminal/apps/web/src/app/lib/use-ticker.ts +++ b/atlas-terminal/apps/web/src/app/lib/use-ticker.ts @@ -9,6 +9,7 @@ const EVENT_NAME = "atlas-ticker-change"; export function useTicker() { // Must match server render: never read localStorage in useState initializer — hydration mismatch → white screen. const [ticker, setTickerState] = useState(DEFAULT_TICKER); + const [initialized, setInitialized] = useState(false); useEffect(() => { const saved = localStorage.getItem(STORAGE_KEY); @@ -16,6 +17,7 @@ export function useTicker() { const n = normalizeTickerInput(saved); if (n) setTickerState(n); } + setInitialized(true); const handler = (e: Event) => { const detail = (e as CustomEvent).detail; @@ -33,5 +35,5 @@ export function useTicker() { window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: upper })); }, []); - return { ticker, setTicker }; + return { ticker, setTicker, initialized }; } diff --git a/atlas-terminal/apps/web/src/app/macro/page.tsx b/atlas-terminal/apps/web/src/app/macro/page.tsx index 747ec7b..6981456 100644 --- a/atlas-terminal/apps/web/src/app/macro/page.tsx +++ b/atlas-terminal/apps/web/src/app/macro/page.tsx @@ -22,7 +22,23 @@ interface FredPoint { value: number; } -type MacroTab = "fred" | "cycle" | "oecd" | "korea" | "calendar"; +type MacroTab = "fred" | "cycle" | "oecd" | "korea" | "calendar" | "subfactors"; + +interface SubfactorIndicator { + key: string; + label: string; + value: number | null; + unit?: string; + change_3m: number | null; + zscore: number | null; + signal: "improving" | "neutral" | "deteriorating"; +} + +interface SubfactorData { + composite_score: number; + cycle_stage: string; + categories: Record; +} export default function MacroPage() { const [series, setSeries] = useState("UNRATE"); @@ -36,6 +52,7 @@ export default function MacroPage() { const [oecd, setOecd] = useState(null); const [classicOpen, setClassicOpen] = useState(false); + const [subfactors, setSubfactors] = useState(null); const [quadPoints, setQuadPoints] = useState([]); const [quadErr, setQuadErr] = useState(null); @@ -88,6 +105,15 @@ export default function MacroPage() { .catch(() => setSnap(null)); }, []); + useEffect(() => { + fetch("/api/macro/subfactors") + .then((r) => (r.ok ? r.json() : null)) + .then((j) => { + if (j && typeof j === "object" && j.categories) setSubfactors(j); + }) + .catch(() => setSubfactors(null)); + }, []); + useEffect(() => { fetch("/api/macro/oecd/cli") .then((r) => (r.ok ? r.json() : null)) @@ -150,6 +176,7 @@ export default function MacroPage() { const tabs: { key: MacroTab; label: string }[] = [ { key: "fred", label: "FRED" }, + { key: "subfactors", label: "Sub-Factors" }, { key: "cycle", label: "Cycle & valuation" }, { key: "oecd", label: "OECD CLI" }, { key: "korea", label: "Korea" }, @@ -291,6 +318,63 @@ export default function MacroPage() { )} + {tab === "subfactors" && ( +
+ {subfactors ? ( + <> +
+
+
Cycle Stage
+
{subfactors.cycle_stage}
+
+
+
Composite Score
+
= 0 ? "text-accent-green" : "text-accent-red"}`}> + {subfactors.composite_score >= 0 ? "+" : ""}{subfactors.composite_score.toFixed(2)} +
+
+
+
+ {Object.entries(subfactors.categories).map(([catName, cat]) => ( +
+
+

{catName}

+ = 0.5 ? "bg-accent-green/15 text-accent-green" : cat.score <= -0.5 ? "bg-accent-red/15 text-accent-red" : "bg-bg-hover text-text-muted"}`}> + {cat.score >= 0 ? "+" : ""}{cat.score.toFixed(2)} + +
+
+ {cat.indicators.map((ind) => ( +
+ {ind.label} +
+ {ind.value != null ? ind.value : "—"}{ind.unit && ind.value != null ? ind.unit : ""} + {ind.change_3m != null && ( + = 0 ? "text-accent-green" : "text-accent-red"}`}> + {ind.change_3m >= 0 ? "+" : ""}{ind.change_3m}% + + )} + + {ind.signal} + +
+
+ ))} +
+
+ ))} +
+ + ) : ( +
Loading sub-factor data...
+ )} +
+ )} + {tab === "cycle" && (
{snapErr && ( diff --git a/atlas-terminal/apps/web/src/app/markets/page.tsx b/atlas-terminal/apps/web/src/app/markets/page.tsx index 45f6ae2..239428d 100644 --- a/atlas-terminal/apps/web/src/app/markets/page.tsx +++ b/atlas-terminal/apps/web/src/app/markets/page.tsx @@ -128,7 +128,7 @@ const ROW_MAP: Record = { }; export default function MarketsPage() { - const { ticker } = useTicker(); + const { ticker, initialized } = useTicker(); const [data, setData] = useState(null); const [tab, setTab] = useState("income_statement"); const [viewTab, setViewTab] = useState("overview"); @@ -138,6 +138,7 @@ export default function MarketsPage() { const [loading, setLoading] = useState(true); useEffect(() => { + if (!initialized) return; setLoading(true); fetch(`/api/financials/${ticker}/statements`) .then((r) => (r.ok ? r.json() : null)) @@ -146,7 +147,7 @@ export default function MarketsPage() { setLoading(false); }) .catch(() => setLoading(false)); - }, [ticker]); + }, [ticker, initialized]); useEffect(() => { fetch("/api/market/overview") diff --git a/atlas-terminal/apps/web/src/app/news/page.tsx b/atlas-terminal/apps/web/src/app/news/page.tsx index 9678bc8..1af67dd 100644 --- a/atlas-terminal/apps/web/src/app/news/page.tsx +++ b/atlas-terminal/apps/web/src/app/news/page.tsx @@ -81,7 +81,7 @@ interface QuoteRow { } export default function NewsPage() { - const { ticker } = useTicker(); + const { ticker, initialized } = useTicker(); const [news, setNews] = useState([]); const [loading, setLoading] = useState(true); const [selected, setSelected] = useState(null); @@ -90,6 +90,7 @@ export default function NewsPage() { const [mentionQuotes, setMentionQuotes] = useState>({}); useEffect(() => { + if (!initialized) return; setLoading(true); setSelected(null); fetch(`/api/news/${ticker}`) @@ -99,7 +100,7 @@ export default function NewsPage() { setLoading(false); }) .catch(() => setLoading(false)); - }, [ticker]); + }, [ticker, initialized]); const filtered = useMemo(() => { const kw = keyword.trim().toLowerCase(); @@ -305,8 +306,7 @@ export default function NewsPage() { {isIframeEmbeddingBlocked(selected.url) ? (

- 이 출처(Yahoo·Bloomberg 등)는 보안 정책으로 미리보기 iframe을 - 허용하지 않습니다. 원문은 새 탭에서 열어 주세요. + This source (Yahoo, Bloomberg, etc.) does not allow iframe preview due to security policies. Please open the original article in a new tab.

{selected.summary ? (
@@ -320,7 +320,7 @@ export default function NewsPage() { rel="noopener noreferrer" className="inline-flex items-center gap-2 px-5 py-2.5 bg-accent-green text-bg-primary rounded-lg font-semibold text-sm hover:opacity-90" > - 원문 열기 ↗ + Open Article ↗
) : ( diff --git a/atlas-terminal/apps/web/src/app/page.tsx b/atlas-terminal/apps/web/src/app/page.tsx index e1c3626..68f91ff 100644 --- a/atlas-terminal/apps/web/src/app/page.tsx +++ b/atlas-terminal/apps/web/src/app/page.tsx @@ -6,7 +6,7 @@ import { ETFOverview } from "./components/overview/ETFOverview"; import { CommodityOverview } from "./components/overview/CommodityOverview"; export default function OverviewPage() { - const { ticker } = useTicker(); + const { ticker, initialized } = useTicker(); const [sector, setSector] = useState | null>(null); const [health, setHealth] = useState | null>(null); const [overview, setOverview] = useState | null>(null); @@ -14,19 +14,26 @@ export default function OverviewPage() { const [loading, setLoading] = useState(true); useEffect(() => { + if (!initialized) return; + const ac = new AbortController(); setLoading(true); + setSector(null); + setHealth(null); + setOverview(null); 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), - fetch(`/api/market/overview/${ticker}`).then((r) => r.ok ? r.json() : null), + fetch(`/api/market/sector/${ticker}`, { signal: ac.signal }).then((r) => r.ok ? r.json() : null), + fetch(`/api/market/health/${ticker}`, { signal: ac.signal }).then((r) => r.ok ? r.json() : null), + fetch(`/api/market/overview/${ticker}`, { signal: ac.signal }).then((r) => r.ok ? r.json() : null), ]).then(([s, h, d]) => { + if (ac.signal.aborted) return; setSector(s); setHealth(h); setAssetType(d?.asset_type || "equity"); setOverview(d?.data || null); setLoading(false); - }).catch(() => setLoading(false)); - }, [ticker]); + }).catch(() => { if (!ac.signal.aborted) setLoading(false); }); + return () => ac.abort(); + }, [ticker, initialized]); if (loading) return ; diff --git a/atlas-terminal/apps/web/src/app/portfolio/page.tsx b/atlas-terminal/apps/web/src/app/portfolio/page.tsx index 8363ed9..6054bf2 100644 --- a/atlas-terminal/apps/web/src/app/portfolio/page.tsx +++ b/atlas-terminal/apps/web/src/app/portfolio/page.tsx @@ -117,7 +117,7 @@ export default function PortfolioPage() { const geminiKey = localStorage.getItem("atlas_gemini_key") || ""; if (!geminiKey.trim()) { setOcrPositions([]); - setOcrError("Gemini API 키가 없습니다. Settings에서 Gemini API Key를 먼저 저장하세요."); + setOcrError("Gemini API key is missing. Please save your Gemini API Key in Settings first."); return; } @@ -142,7 +142,7 @@ export default function PortfolioPage() { 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은 완료됐지만 포지션을 찾지 못했습니다. 표가 선명하게 보이는 스크린샷으로 다시 시도하세요."); + setOcrError("OCR completed but no positions were found. Try again with a clearer screenshot showing the table."); } } } catch { @@ -179,7 +179,7 @@ export default function PortfolioPage() { } } if (failed.length > 0) { - setOcrError(`일부 저장 실패: ${failed.join(", ")}. Import Results를 유지합니다.`); + setOcrError(`Some imports failed: ${failed.join(", ")}. Keeping Import Results.`); return; } setOcrPositions([]); @@ -369,11 +369,11 @@ export default function PortfolioPage() { onClick={() => document.getElementById("ocr-file-input")?.click()} > {isProcessing ? ( -

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

+

AI is analyzing positions...

) : ( <> 📸 -

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

+

Drag & drop a Trading 212 / IBKR screenshot

)}
@@ -604,8 +604,8 @@ export default function PortfolioPage() { {deleteConfirmId && (
setDeleteConfirmId(null)}>
e.stopPropagation()}> -

정말 삭제하시겠습니까?

-

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

+

Are you sure you want to delete this?

+

This action cannot be undone.

diff --git a/atlas-terminal/apps/web/src/app/report/page.tsx b/atlas-terminal/apps/web/src/app/report/page.tsx index 513e6be..e589aa3 100644 --- a/atlas-terminal/apps/web/src/app/report/page.tsx +++ b/atlas-terminal/apps/web/src/app/report/page.tsx @@ -43,6 +43,28 @@ interface QuarterlyEarnings { period: string; revenue: number | null; earnings: interface HealthData { dupont: Record; altman_z: number | null; current_ratio: number | null; interest_coverage: number | null; debt_to_equity: number | null; red_flags: string[] } interface TechnicalData { [key: string]: any } +type ValuationTier = "dcf" | "ev_ebitda" | "ps_revenue" | "pb_nav"; +interface RelativeValData { + tier: ValuationTier; + tierLabel: string; + tierReason: string; + method: string; + multipleName: string; + peerAvgMultiple: number; + companyMetric: number; + metricLabel: string; + bear: { multiple: number; value: number }; + base: { multiple: number; value: number }; + bull: { multiple: number; value: number }; + netDebt: number; + shares: number; + cashRunwayQuarters: number | null; + revenueGrowth: number | null; + rule40: number | null; + ebitda: number | null; + fcf: number | null; +} + /* ═══════════════════════════════════════════════════════════════════ Utility ═══════════════════════════════════════════════════════════════════ */ @@ -76,7 +98,7 @@ function renderMarkdown(text: string) { SECTION COMPONENTS ═══════════════════════════════════════════════════════════════════ */ -function CoverPage({ ticker, info, consensus, dcf }: { ticker: string; info: Record; consensus: ConsensusData | null; dcf: DCFResult | null }) { +function CoverPage({ ticker, info, consensus, dcf, relativeVal }: { ticker: string; info: Record; consensus: ConsensusData | null; dcf: DCFResult | null; relativeVal: RelativeValData | null }) { const now = new Date(); const dateStr = now.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }); const price = info.currentPrice || info.regularMarketPrice || 0; @@ -115,7 +137,7 @@ function CoverPage({ ticker, info, consensus, dcf }: { ticker: string; info: Rec {[ { label: "Price", value: fmtPrice(price) }, { label: "Market Cap", value: fmtB(info.marketCap) }, - { label: "DCF Fair Value", value: dcf?.base != null ? fmtPrice(dcf.base) : "N/A" }, + { label: relativeVal ? `${relativeVal.method} Fair Value` : "DCF Fair Value", value: relativeVal ? fmtPrice(relativeVal.base.value) : dcf?.base != null ? fmtPrice(dcf.base) : "N/A" }, { label: "Analysts", value: consensus ? `${consensus.num_analysts}` : "N/A" }, ].map((k) => (
@@ -810,6 +832,194 @@ function TechnicalSnapshot({ technical, info }: { technical: TechnicalData | nul ); } +/* ── Valuation Tier Detection ── */ +function detectValuationTier(fcf: number | null, ebitda: number | null, revenueGrowth: number | null): ValuationTier { + if (fcf != null && fcf > 0) return "dcf"; + if (ebitda != null && ebitda > 0) return "ev_ebitda"; + if (revenueGrowth != null && revenueGrowth > 0.10) return "ps_revenue"; + return "pb_nav"; +} + +function buildRelativeVal( + tier: ValuationTier, peers: PeerData | null, info: Record, + di: { fcf: number; total_debt: number; cash: number; shares: number } | null, hi: any, +): RelativeValData | null { + if (!di || !di.shares || di.shares <= 0) return null; + const netDebt = (di.total_debt || 0) - (di.cash || 0); + const revenue = hi?.revenue || info.totalRevenue || 0; + const ebitda = hi?.ebitda || 0; + const fcf = hi?.free_cash_flow ?? info.freeCashflow ?? di.fcf ?? 0; + const revGrowth = hi?.revenue_growth ?? info.revenueGrowth ?? null; + const profitMargin = hi?.profit_margin ?? info.profitMargins ?? 0; + const rule40 = revGrowth != null ? (revGrowth * 100) + (profitMargin * 100) : null; + const cash = di.cash || 0; + const qBurn = fcf < 0 ? Math.abs(fcf) / 4 : 0; + const cashRunway = qBurn > 0 ? cash / qBurn : null; + + if (tier === "ev_ebitda") { + const peerAvg = peers?.averages?.ev_ebitda ?? 12; + const impliedEV = peerAvg * ebitda; + const baseVal = (impliedEV - netDebt) / di.shares; + return { + tier, tierLabel: "EV/EBITDA Relative Valuation", + tierReason: "Free cash flow is negative due to heavy capital investment, but EBITDA is positive — the company generates operating profit before reinvestment.", + method: "EV/EBITDA", multipleName: "EV/EBITDA", peerAvgMultiple: peerAvg, + companyMetric: ebitda, metricLabel: "EBITDA", + bear: { multiple: peerAvg * 0.7, value: (peerAvg * 0.7 * ebitda - netDebt) / di.shares }, + base: { multiple: peerAvg, value: baseVal }, + bull: { multiple: peerAvg * 1.3, value: (peerAvg * 1.3 * ebitda - netDebt) / di.shares }, + netDebt, shares: di.shares, cashRunwayQuarters: cashRunway, revenueGrowth: revGrowth, rule40, ebitda, fcf, + }; + } + if (tier === "ps_revenue") { + const peerAvg = peers?.averages?.ps ?? 4; + const impliedMC = peerAvg * revenue; + const baseVal = impliedMC / di.shares; + return { + tier, tierLabel: "Price/Sales Relative Valuation", + tierReason: "Both FCF and EBITDA are negative, but revenue is growing rapidly. P/S (Price-to-Sales) multiple is the appropriate valuation framework for high-growth, pre-profit companies.", + method: "P/S", multipleName: "P/S", peerAvgMultiple: peerAvg, + companyMetric: revenue, metricLabel: "Revenue", + bear: { multiple: peerAvg * 0.6, value: (peerAvg * 0.6 * revenue) / di.shares }, + base: { multiple: peerAvg, value: baseVal }, + bull: { multiple: peerAvg * 1.5, value: (peerAvg * 1.5 * revenue) / di.shares }, + netDebt, shares: di.shares, cashRunwayQuarters: cashRunway, revenueGrowth: revGrowth, rule40, ebitda, fcf, + }; + } + // pb_nav + const bookVal = hi?.book_value || 0; + const peerAvg = peers?.averages?.pb ?? 2; + const baseVal = bookVal > 0 ? bookVal * peerAvg : 0; + return { + tier, tierLabel: "Price/Book (NAV) Valuation", + tierReason: "FCF, EBITDA, and revenue growth are all weak or negative. Asset-based valuation (P/B) provides the most relevant framework.", + method: "P/B", multipleName: "P/B", peerAvgMultiple: peerAvg, + companyMetric: bookVal * di.shares, metricLabel: "Book Value", + bear: { multiple: peerAvg * 0.6, value: bookVal * peerAvg * 0.6 }, + base: { multiple: peerAvg, value: baseVal }, + bull: { multiple: peerAvg * 1.5, value: bookVal * peerAvg * 1.5 }, + netDebt, shares: di.shares, cashRunwayQuarters: cashRunway, revenueGrowth: revGrowth, rule40, ebitda, fcf, + }; +} + +/* ── Relative Valuation Section ── */ +function RelativeValuationSection({ rv, info }: { rv: RelativeValData; info: Record }) { + const price = info.currentPrice || 0; + const scenarios = [ + { label: "Bear", ...rv.bear, color: C.red }, + { label: "Base", ...rv.base, color: C.blue }, + { label: "Bull", ...rv.bull, color: C.green }, + ]; + const chartData = scenarios.map((s) => ({ name: `${s.label} (${s.multiple.toFixed(1)}x)`, value: Math.max(s.value, 0), fill: s.color })); + + return ( +
+ {/* Tier Banner */} +
+
+ + DCF Not Applicable — {rv.tierLabel} +
+

{rv.tierReason}

+
+ +

{rv.method} Scenario Analysis

+
+
+

Peer Avg {rv.multipleName}: {rv.peerAvgMultiple.toFixed(1)}x • {rv.metricLabel}: {fmtB(rv.companyMetric)}

+ + + + `$${v.toFixed(0)}`} /> + + fmtPrice(v)} /> + {chartData.map((d, i) => )} + {price > 0 && } + + +
+
+ {scenarios.map((s) => { + const upside = price > 0 ? ((s.value - price) / price) * 100 : 0; + return ( +
+
+
{s.label} ({s.multiple.toFixed(1)}x)
+
{fmtPrice(Math.max(s.value, 0))}
+
+
+
vs Current
+
= 0 ? C.green : C.red }}>{fmtPct(upside)}
+
+
+ ); + })} +
+
+
+ ); +} + +/* ── Path to Profitability ── */ +function PathToProfitability({ rv, stmts }: { rv: RelativeValData; stmts: { income_statement?: FinancialPeriod[] } }) { + const is = stmts.income_statement; + // Compute margin trends from statements + const marginData = (is || []).slice(0, 5).reverse().map((p) => { + const rev = getValue(p, "TotalRevenue|Total Revenue|Revenue"); + const gp = getValue(p, "GrossProfit|Gross Profit"); + const op = getValue(p, "OperatingIncome|Operating Income"); + const ni = getValue(p, "NetIncome|Net Income|Net Income Common Stockholders"); + const yr = p.asOfDate || p.fiscalYear || ""; + return { + year: typeof yr === "string" ? yr.slice(0, 4) : String(yr), + grossMargin: rev && gp ? (gp / rev) * 100 : null, + opMargin: rev && op ? (op / rev) * 100 : null, + netMargin: rev && ni ? (ni / rev) * 100 : null, + }; + }); + + const kpis = [ + { l: "FCF (TTM)", v: fmtB(rv.fcf), color: (rv.fcf ?? 0) >= 0 ? C.green : C.red }, + { l: "EBITDA (TTM)", v: fmtB(rv.ebitda), color: (rv.ebitda ?? 0) > 0 ? C.green : C.red }, + { l: "Revenue Growth", v: rv.revenueGrowth != null ? fmtPct(rv.revenueGrowth * 100) : "N/A", color: (rv.revenueGrowth ?? 0) > 0 ? C.green : C.red }, + { l: "Rule of 40", v: rv.rule40 != null ? rv.rule40.toFixed(1) : "N/A", color: (rv.rule40 ?? 0) >= 40 ? C.green : (rv.rule40 ?? 0) >= 20 ? C.gold : C.red }, + { l: "Cash Runway", v: rv.cashRunwayQuarters != null ? `${rv.cashRunwayQuarters.toFixed(1)} Q` : "N/A", color: (rv.cashRunwayQuarters ?? 0) > 8 ? C.green : (rv.cashRunwayQuarters ?? 0) > 4 ? C.gold : C.red }, + { l: "Net Debt", v: fmtB(rv.netDebt), color: rv.netDebt > 0 ? C.red : C.green }, + ]; + + return ( +
+

Path to Profitability

+
+ {kpis.map((k) => ( +
+
{k.l}
+
{k.v}
+
+ ))} +
+ {marginData.length > 1 && ( +
+

Margin Trajectory — Is Profitability Approaching?

+ + + + + `${v}%`} /> + `${v?.toFixed(1)}%`} /> + + + + + + + +
+ )} +
+ ); +} + /* ── Wall Street 10 ── */ function WallStreet10Section({ sections }: { sections: Record }) { const order: [string, string, string][] = [ @@ -887,6 +1097,8 @@ export default function ReportPage() { const [tornado, setTornado] = useState(null); const [reverseDcf, setReverseDcf] = useState(null); const [institutional, setInstitutional] = useState(null); + const [valTier, setValTier] = useState("dcf"); + const [relativeVal, setRelativeVal] = useState(null); const [loading, setLoading] = useState(false); const [progress, setProgress] = useState(""); const [error, setError] = useState(""); @@ -959,8 +1171,8 @@ export default function ReportPage() { const te = rTe.status === "fulfilled" ? rTe.value : null; if (te) setTechnical(te); - /* ── Phase 2: DCF Inputs ── */ - setProgress("Phase 2/4 — Loading DCF inputs..."); + /* ── Phase 2: DCF Inputs + Tier Detection ── */ + setProgress("Phase 2/4 — Loading valuation inputs & detecting tier..."); const [dcfInputs, smartDefaults] = await Promise.allSettled([ fetchJson(`/api/valuation/dcf-inputs/${ticker}`), fetchJson(`/api/valuation/smart-defaults/${ticker}`), @@ -968,8 +1180,15 @@ export default function ReportPage() { const di = dcfInputs.status === "fulfilled" ? dcfInputs.value : null; const sd = smartDefaults.status === "fulfilled" ? smartDefaults.value : null; - if (di && sd && di.fcf && di.shares) { - // smart-defaults returns percentage (e.g. 10 = 10%), but POST endpoints expect decimal (0.10) + // Detect valuation tier + const fcfVal = hi?.free_cash_flow ?? di?.fcf ?? null; + const ebitdaVal = hi?.ebitda ?? null; + const revGrowthVal = hi?.revenue_growth ?? null; + const tier = detectValuationTier(fcfVal, ebitdaVal, revGrowthVal); + setValTier(tier); + + if (tier === "dcf" && di && sd && di.fcf && di.shares) { + // Tier 1: Full DCF analysis const waccDec = (sd.wacc || 9) / 100; const tgDec = (sd.terminal_growth || 2.5) / 100; const growthDec = (sd.fcf_growth || 10) / 100; @@ -984,8 +1203,7 @@ export default function ReportPage() { term_growth: tgDec, n_simulations: 5000, }; - /* ── Phase 3: DCF Calculations (5 parallel) ── */ - setProgress("Phase 3/4 — Running valuation models (DCF, Sensitivity, Monte Carlo, Tornado, Reverse DCF)..."); + setProgress("Phase 3/4 — Running DCF models (5 parallel)..."); const [rDcf, rSens, rMc, rTor, rRev] = await Promise.allSettled([ fetchJson("/api/valuation/dcf", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(params) }), fetchJson("/api/valuation/sensitivity", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(params) }), @@ -999,6 +1217,11 @@ export default function ReportPage() { if (rMc.status === "fulfilled" && rMc.value) setMonteCarlo(rMc.value); if (rTor.status === "fulfilled" && rTor.value?.data) setTornado(rTor.value.data); if (rRev.status === "fulfilled" && rRev.value) setReverseDcf(rRev.value); + } else if (tier !== "dcf") { + // Tier 2/3/4: Relative valuation + setProgress("Phase 3/4 — Computing relative valuation (peer multiples)..."); + const rv = buildRelativeVal(tier, pr, mergedInfo, di, hi); + if (rv) setRelativeVal(rv); } /* ── Phase 4: Gemini AI ── */ @@ -1022,7 +1245,7 @@ export default function ReportPage() { const handlePrint = useCallback(() => { window.print(); }, []); const hasReport = institutional && institutional.sections; - const hasData = Object.keys(info).length > 0 || Object.keys(stmts).length > 0 || research != null || dcf != null; + const hasData = Object.keys(info).length > 0 || Object.keys(stmts).length > 0 || research != null || dcf != null || relativeVal != null; const currentPrice = info.currentPrice || info.regularMarketPrice || dcf?.current_price || 0; return ( @@ -1084,7 +1307,7 @@ export default function ReportPage() { {(hasReport || hasData) && (
{/* Page 1: Cover */} - + {/* Page 2: TOC */} @@ -1124,29 +1347,40 @@ export default function ReportPage() {
) : null} - {/* Page 7: DCF Valuation */} - {dcf && ( -
-
Valuation — DCF Analysis
- -
+ {/* ── Valuation Pages (Tier-Dependent) ── */} + {valTier === "dcf" && dcf && ( + <> +
+
Valuation — DCF Analysis
+ +
+ {(sensitivity || monteCarlo) && ( +
+
Valuation — Sensitivity & Monte Carlo
+ + +
+ )} + {tornado && tornado.length > 0 && ( +
+
Valuation — Tornado Sensitivity
+ +
+ )} + )} - {/* Page 8: Sensitivity + Monte Carlo */} - {(sensitivity || monteCarlo) && ( -
-
Valuation — Sensitivity & Monte Carlo
- - -
- )} - - {/* Page 9: Tornado */} - {tornado && tornado.length > 0 && ( -
-
Valuation — Tornado Sensitivity
- -
+ {valTier !== "dcf" && relativeVal && ( + <> +
+
Valuation — {relativeVal.tierLabel}
+ +
+
+
Path to Profitability
+ +
+ )} {/* Page 10: Peer Comparison */} diff --git a/atlas-terminal/apps/web/src/app/research/page.tsx b/atlas-terminal/apps/web/src/app/research/page.tsx index bc2f4d2..cb4fd3c 100644 --- a/atlas-terminal/apps/web/src/app/research/page.tsx +++ b/atlas-terminal/apps/web/src/app/research/page.tsx @@ -6,13 +6,14 @@ import type { ResearchDashboardPayload } from "../components/research/types"; import { useTicker } from "../lib/use-ticker"; export default function ResearchPage() { - const { ticker } = useTicker(); + const { ticker, initialized } = useTicker(); const [assetType, setAssetType] = useState("equity"); const [dashboard, setDashboard] = useState(null); const [loadError, setLoadError] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { + if (!initialized) return; setLoading(true); setLoadError(null); Promise.all([ @@ -30,11 +31,11 @@ export default function ResearchPage() { setDashboard(dash as ResearchDashboardPayload); }) .catch(() => { - setLoadError("대시보드 데이터를 불러오지 못했습니다."); + setLoadError("Failed to load dashboard data."); setDashboard(null); }) .finally(() => setLoading(false)); - }, [ticker]); + }, [ticker, initialized]); if (loading) { return ( @@ -57,29 +58,28 @@ export default function ResearchPage() { <> {dashboard.error && (
- {dashboard.error} — 일부 위젯이 비어 있을 수 있습니다. + {dashboard.error} — Some widgets may be empty.
)} ) : (
- 대시보드 데이터가 없습니다. API 응답을 확인하거나 티커를 바꿔 보세요. + No dashboard data available. Check the API response or try a different ticker.
) ) : assetType === "etf" ? (

ETF Research

- Holdings Analysis, Sector Breakdown, Overlap Analysis를 우선 제공합니다. Piotroski/F-Score 및 기업 재무 - 대시보드는 ETF에 적용되지 않습니다. + Provides Holdings Analysis, Sector Breakdown, and Overlap Analysis. Piotroski F-Score and corporate financial dashboards are not applicable to ETFs.
) : (

Commodity Research

- Seasonal Analysis와 Supply/Demand 요인을 중심으로 분석합니다. 주식 전용 지표는 표시하지 않습니다. + Focuses on Seasonal Analysis and Supply/Demand factors. Equity-specific indicators are not displayed.
)} diff --git a/atlas-terminal/apps/web/src/app/screener/page.tsx b/atlas-terminal/apps/web/src/app/screener/page.tsx index d15e97b..0fd9ccd 100644 --- a/atlas-terminal/apps/web/src/app/screener/page.tsx +++ b/atlas-terminal/apps/web/src/app/screener/page.tsx @@ -26,8 +26,23 @@ interface BacktestResult { benchmark_ticker?: string; } +interface PortfolioResult { + error?: string; + total_return_pct?: number; + benchmark_return_pct?: number; + alpha?: number; + sharpe_ratio?: number; + sortino_ratio?: number; + max_drawdown_pct?: number; + contributions?: Record; + equity_curve?: number[]; + benchmark_curve?: number[]; + dates?: string[]; + benchmark_ticker?: string; +} + export default function ScreenerPage() { - const [tab, setTab] = useState<"screener" | "backtest">("screener"); + const [tab, setTab] = useState<"screener" | "backtest" | "portfolio">("screener"); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(false); const [peMax, setPeMax] = useState("25"); @@ -44,6 +59,42 @@ export default function ScreenerPage() { const [btLoading, setBtLoading] = useState(false); const chartRef = useRef(null); + // Portfolio backtest state + const [ptTickers, setPtTickers] = useState("AAPL, MSFT, GOOG"); + const [ptWeights, setPtWeights] = useState("40, 30, 30"); + const [ptBenchmark, setPtBenchmark] = useState("SPY"); + const [ptRebal, setPtRebal] = useState("3"); + const [ptStart, setPtStart] = useState("2021-01-01"); + const [ptEnd, setPtEnd] = useState("2026-01-01"); + const [ptResult, setPtResult] = useState(null); + const [ptLoading, setPtLoading] = useState(false); + const ptChartRef = useRef(null); + + async function runPortfolioBacktest() { + setPtLoading(true); + try { + const tickers = ptTickers.split(",").map((s) => s.trim().toUpperCase()).filter(Boolean); + const weights = ptWeights.split(",").map((s) => parseFloat(s.trim())).filter((n) => !isNaN(n)); + const res = await fetch("/api/screener/portfolio-backtest", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + tickers, + weights: weights.length === tickers.length ? weights : undefined, + start_date: ptStart, + end_date: ptEnd, + rebalance_months: parseInt(ptRebal, 10) || 3, + benchmark_ticker: ptBenchmark || "SPY", + }), + }); + setPtResult(await res.json()); + } catch { + setPtResult(null); + } finally { + setPtLoading(false); + } + } + async function runScreener() { setLoading(true); try { @@ -138,6 +189,53 @@ export default function ScreenerPage() { }; }, [btResult]); + useEffect(() => { + if (!ptResult?.equity_curve || !ptResult.benchmark_curve || !ptResult.dates || !ptChartRef.current) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let chart: any = null; + const el = ptChartRef.current; + let onResize: (() => void) | null = null; + + import("lightweight-charts") + .then(({ createChart }) => { + if (!el) return; + el.innerHTML = ""; + chart = createChart(el, { + width: el.clientWidth, + height: 320, + layout: { background: { color: "#1A1A26" }, textColor: "#9CA3AF" }, + grid: { vertLines: { color: "#2A2A3A" }, horzLines: { color: "#2A2A3A" } }, + crosshair: { mode: 0 }, + timeScale: { borderColor: "#2A2A3A" }, + }); + const port = chart.addLineSeries({ color: "#00D4AA", lineWidth: 2 }); + port.setData( + ptResult.dates!.map((d, i) => ({ + time: d as string & { __brand?: "Time" }, + value: ptResult.equity_curve![i], + })) + ); + const bench = chart.addLineSeries({ color: "#4DA6FF", lineWidth: 2 }); + bench.setData( + ptResult.dates!.map((d, i) => ({ + time: d as string & { __brand?: "Time" }, + value: ptResult.benchmark_curve![i], + })) + ); + chart.timeScale().fitContent(); + onResize = () => { + if (el && chart) chart.applyOptions({ width: el.clientWidth }); + }; + window.addEventListener("resize", onResize); + }) + .catch(() => {}); + + return () => { + if (onResize) window.removeEventListener("resize", onResize); + if (chart) chart.remove(); + }; + }, [ptResult]); + return (

Stock Screener

@@ -157,11 +255,19 @@ export default function ScreenerPage() { > Backtest +
{tab === "screener" ? (
+ setPeMax(e.target.value)} @@ -213,7 +319,7 @@ export default function ScreenerPage() {
- ) : ( + ) : tab === "backtest" ? (
{btResult.error}

}
- )} + ) : tab === "portfolio" ? ( +
+
+ setPtTickers(e.target.value.toUpperCase())} + placeholder="Tickers (comma sep)" + className="bg-bg-primary border border-border rounded px-3 py-2 text-sm flex-1 min-w-[200px]" + /> + setPtWeights(e.target.value)} + placeholder="Weights (e.g. 40, 30, 30)" + className="bg-bg-primary border border-border rounded px-3 py-2 text-sm w-48" + /> + setPtBenchmark(e.target.value.toUpperCase())} + placeholder="Benchmark" + className="bg-bg-primary border border-border rounded px-3 py-2 text-sm w-28" + /> + setPtRebal(e.target.value)} + placeholder="Rebal months" + className="bg-bg-primary border border-border rounded px-3 py-2 text-sm w-28" + /> + setPtStart(e.target.value)} className="bg-bg-primary border border-border rounded px-3 py-2 text-sm" /> + setPtEnd(e.target.value)} className="bg-bg-primary border border-border rounded px-3 py-2 text-sm" /> + +
+

+ Multi-asset portfolio (green) vs benchmark (blue). Weights are rebalanced every N months. +

+ {ptResult && !ptResult.error && ( + <> +
+ + + + + + +
+ {ptResult.contributions && ( +
+
Contribution by Ticker
+
+ {Object.entries(ptResult.contributions).map(([t, c]) => ( +
+ {t}{" "} + = 0 ? "text-accent-green" : "text-accent-red"}> + {c >= 0 ? "+" : ""}{c}% + +
+ ))} +
+
+ )} +
+ + )} + {ptResult?.error &&

{ptResult.error}

} +
+ ) : null}
); } diff --git a/atlas-terminal/apps/web/src/app/technical/page.tsx b/atlas-terminal/apps/web/src/app/technical/page.tsx index 9969d92..b579123 100644 --- a/atlas-terminal/apps/web/src/app/technical/page.tsx +++ b/atlas-terminal/apps/web/src/app/technical/page.tsx @@ -31,7 +31,7 @@ interface FibLevels { } export default function TechnicalPage() { - const { ticker } = useTicker(); + const { ticker, initialized } = useTicker(); const [indicators, setIndicators] = useState(null); const [bars, setBars] = useState([]); const [fib, setFib] = useState(null); @@ -40,6 +40,7 @@ export default function TechnicalPage() { const chartRef = useRef(null); useEffect(() => { + if (!initialized) return; setLoading(true); Promise.all([ fetch(`/api/technical/${ticker}/indicators`).then((r) => r.ok ? r.json() : null), @@ -51,19 +52,19 @@ export default function TechnicalPage() { setFib(fibData); setLoading(false); }).catch(() => setLoading(false)); - }, [ticker, period]); + }, [ticker, period, initialized]); - // Render chart using lightweight-charts + // Render chart using lightweight-charts v5 useEffect(() => { if (!chartRef.current || bars.length === 0) return; - // v5 typings omit series helpers; runtime still exposes addCandlestickSeries etc. // eslint-disable-next-line @typescript-eslint/no-explicit-any let chart: any = null; + let resizeHandler: (() => void) | null = null; (async () => { try { - const { createChart } = await import("lightweight-charts"); + const lc = await import("lightweight-charts"); chartRef.current!.innerHTML = ""; - chart = createChart(chartRef.current!, { + chart = lc.createChart(chartRef.current!, { width: chartRef.current!.clientWidth, height: 400, layout: { background: { color: "#1A1A26" }, textColor: "#9CA3AF" }, @@ -71,43 +72,79 @@ export default function TechnicalPage() { crosshair: { mode: 0 }, timeScale: { borderColor: "#2A2A3A" }, }); - const candlestickSeries = chart.addCandlestickSeries({ - upColor: "#00D4AA", - downColor: "#FF4757", - borderUpColor: "#00D4AA", - borderDownColor: "#FF4757", - wickUpColor: "#00D4AA", - wickDownColor: "#FF4757", - }); - candlestickSeries.setData(bars); - const volumeSeries = chart.addHistogramSeries({ - priceFormat: { type: "volume" }, - priceScaleId: "", - }); - volumeSeries.priceScale().applyOptions({ - scaleMargins: { top: 0.8, bottom: 0 }, - }); - volumeSeries.setData( - bars.map((b) => ({ - time: b.time, - value: b.volume, - color: b.close >= b.open ? "rgba(0,212,170,0.3)" : "rgba(255,71,87,0.3)", - })) - ); + // v5 API: use addSeries with series type constructor + const CandlestickSeries = (lc as Record).CandlestickSeries; + const HistogramSeries = (lc as Record).HistogramSeries; + + if (CandlestickSeries && typeof chart.addSeries === "function") { + // v5 path + const candlestickSeries = chart.addSeries(CandlestickSeries, { + upColor: "#00D4AA", + downColor: "#FF4757", + borderUpColor: "#00D4AA", + borderDownColor: "#FF4757", + wickUpColor: "#00D4AA", + wickDownColor: "#FF4757", + }); + candlestickSeries.setData(bars); + + const volumeSeries = chart.addSeries(HistogramSeries, { + priceFormat: { type: "volume" }, + priceScaleId: "volume", + }); + volumeSeries.priceScale().applyOptions({ + scaleMargins: { top: 0.8, bottom: 0 }, + }); + volumeSeries.setData( + bars.map((b: ChartBar) => ({ + time: b.time, + value: b.volume, + color: b.close >= b.open ? "rgba(0,212,170,0.3)" : "rgba(255,71,87,0.3)", + })) + ); + } else if (typeof chart.addCandlestickSeries === "function") { + // v4 fallback + const candlestickSeries = chart.addCandlestickSeries({ + upColor: "#00D4AA", + downColor: "#FF4757", + borderUpColor: "#00D4AA", + borderDownColor: "#FF4757", + wickUpColor: "#00D4AA", + wickDownColor: "#FF4757", + }); + candlestickSeries.setData(bars); + + const volumeSeries = chart.addHistogramSeries({ + priceFormat: { type: "volume" }, + priceScaleId: "", + }); + volumeSeries.priceScale().applyOptions({ + scaleMargins: { top: 0.8, bottom: 0 }, + }); + volumeSeries.setData( + bars.map((b: ChartBar) => ({ + time: b.time, + value: b.volume, + color: b.close >= b.open ? "rgba(0,212,170,0.3)" : "rgba(255,71,87,0.3)", + })) + ); + } chart.timeScale().fitContent(); - const handleResize = () => { + resizeHandler = () => { if (chartRef.current) chart.applyOptions({ width: chartRef.current.clientWidth }); }; - window.addEventListener("resize", handleResize); - return () => window.removeEventListener("resize", handleResize); - } catch { - // lightweight-charts not available + window.addEventListener("resize", resizeHandler); + } catch (e) { + console.error("lightweight-charts render error:", e); } })(); - return () => { if (chart) chart.remove(); }; + return () => { + if (resizeHandler) window.removeEventListener("resize", resizeHandler); + if (chart) chart.remove(); + }; }, [bars]); if (loading) return
Loading...
; diff --git a/atlas-terminal/apps/web/src/app/valuation/page.tsx b/atlas-terminal/apps/web/src/app/valuation/page.tsx index 7bac61d..01f770e 100644 --- a/atlas-terminal/apps/web/src/app/valuation/page.tsx +++ b/atlas-terminal/apps/web/src/app/valuation/page.tsx @@ -50,7 +50,7 @@ interface MonteCarloData { type ValuationTab = "dcf" | "sensitivity" | "montecarlo" | "tornado" | "reverse"; export default function ValuationPage() { - const { ticker } = useTicker(); + const { ticker, initialized } = useTicker(); const [assetType, setAssetType] = useState("equity"); const [inputs, setInputs] = useState(null); const [consensus, setConsensus] = useState(null); @@ -70,6 +70,7 @@ export default function ValuationPage() { const [advLoading, setAdvLoading] = useState(false); useEffect(() => { + if (!initialized) return; setLoading(true); setDcfResult(null); setSensitivity(null); @@ -90,7 +91,7 @@ export default function ValuationPage() { if (d?.fcf_growth) setFcfGrowth(d.fcf_growth); setLoading(false); }).catch(() => setLoading(false)); - }, [ticker]); + }, [ticker, initialized]); async function runDCF() { if (!inputs) return; @@ -184,8 +185,8 @@ export default function ValuationPage() {
{assetType === "etf" - ? "NAV Premium/Discount, Expense 비교, Tracking Error 중심으로 평가합니다. DCF는 주식(EQUITY) 전용입니다." - : "Futures Curve(Contango/Backwardation), Cost of Carry 중심으로 평가합니다. DCF는 주식(EQUITY) 전용입니다."} + ? "Evaluates NAV Premium/Discount, Expense comparison, and Tracking Error. DCF is available for equities only." + : "Evaluates Futures Curve (Contango/Backwardation) and Cost of Carry. DCF is available for equities only."}
diff --git a/atlas-terminal/server/main.py b/atlas-terminal/server/main.py index 1274815..a5f476f 100644 --- a/atlas-terminal/server/main.py +++ b/atlas-terminal/server/main.py @@ -2,11 +2,47 @@ ATLAS Terminal — FastAPI Backend Unified entry point with PostgreSQL + SQLite support. """ +import json +import math import os import logging from contextlib import asynccontextmanager +from typing import Any + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + + +class _NanSafeEncoder(json.JSONEncoder): + """Replace NaN/Inf with None so JSON serialization never crashes.""" + + def default(self, o: Any) -> Any: + return super().default(o) + + def encode(self, o: Any) -> str: + return super().encode(_sanitize(o)) + + +def _sanitize(obj: Any) -> Any: + if isinstance(obj, float): + if math.isnan(obj) or math.isinf(obj): + return None + return obj + if isinstance(obj, dict): + return {k: _sanitize(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return [_sanitize(v) for v in obj] + return obj + + +class NanSafeJSONResponse(JSONResponse): + def render(self, content: Any) -> bytes: + return json.dumps( + _sanitize(content), + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -28,6 +64,7 @@ app = FastAPI( description="Personal Bloomberg Terminal — Hybrid AI + Quantitative Analysis", version="2.0.0", lifespan=lifespan, + default_response_class=NanSafeJSONResponse, ) # CORS — allow local frontend diff --git a/atlas-terminal/server/routers/dart.py b/atlas-terminal/server/routers/dart.py index e946735..49fb373 100644 --- a/atlas-terminal/server/routers/dart.py +++ b/atlas-terminal/server/routers/dart.py @@ -1,4 +1,4 @@ -"""DART Korea — company search (optional ``DART_API_KEY``) + 사업보고서 sections.""" +"""DART Korea — company search (optional ``DART_API_KEY``) + annual report sections.""" from typing import Any, Dict, List @@ -32,7 +32,7 @@ async def dart_search( @router.get( "/sections/{ticker}", response_model=EdgarSectionsResponse, - summary="Korean 사업보고서 sections (DART Open API)", + summary="Korean annual report sections (DART Open API)", ) async def dart_sections( ticker: str, @@ -41,7 +41,7 @@ async def dart_sections( description="Include HTML fragment for in-app viewer", ), ): - """Download latest annual report (사업보고서) and map to SEC-like section keys.""" + """Download latest annual report and map to SEC-like section keys.""" if not dart_filing_is_configured(): return EdgarSectionsResponse( source="dart", @@ -50,7 +50,7 @@ async def dart_sections( status="unconfigured", ) try: - sections, status, html_frag, _rcept = get_dart_sections(ticker) + sections, status, html_frag, rcept_no = get_dart_sections(ticker) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc except FileNotFoundError as exc: @@ -59,6 +59,7 @@ async def dart_sections( raise HTTPException(status_code=500, detail=f"DART download failed: {exc}") from exc html_payload = html_frag if include_html else "" + links = {"DART 원문 공시": f"https://dart.fss.or.kr/dsaf001/main.do?rcpNo={rcept_no}"} if rcept_no else None return EdgarSectionsResponse( source="dart", configured=True, @@ -69,4 +70,5 @@ async def dart_sections( item8=sections.get("item8", ""), item9a=sections.get("item9a", ""), html=html_payload, + links=links, ) diff --git a/atlas-terminal/server/routers/earnings.py b/atlas-terminal/server/routers/earnings.py index 3c72f57..15b66ea 100644 --- a/atlas-terminal/server/routers/earnings.py +++ b/atlas-terminal/server/routers/earnings.py @@ -107,6 +107,79 @@ async def earnings_transcript( } +@router.get("/{ticker}/delta", summary="What changed vs last quarter") +async def earnings_delta(ticker: str) -> Dict[str, Any]: + """Compare the two most recent quarters: revenue/earnings delta + AI summary.""" + try: + import yfinance as yf + + t = yf.Ticker(ticker.upper()) + quarterly = t.quarterly_earnings + if quarterly is None or (hasattr(quarterly, "empty") and quarterly.empty) or len(quarterly) < 2: + return {"ticker": ticker.upper(), "available": False, "message": "Not enough quarterly data"} + + rows = [] + for idx, row in quarterly.iterrows(): + rows.append({ + "period": str(idx), + "revenue": _safe_float(row.get("Revenue")), + "earnings": _safe_float(row.get("Earnings")), + }) + if len(rows) < 2: + return {"ticker": ticker.upper(), "available": False, "message": "Not enough quarterly data"} + + latest, prev = rows[0], rows[1] + rev_delta = None + earn_delta = None + if latest["revenue"] and prev["revenue"] and prev["revenue"] != 0: + rev_delta = round((latest["revenue"] - prev["revenue"]) / abs(prev["revenue"]) * 100, 2) + if latest["earnings"] and prev["earnings"] and prev["earnings"] != 0: + earn_delta = round((latest["earnings"] - prev["earnings"]) / abs(prev["earnings"]) * 100, 2) + + # EPS surprise trend from earnings_history + eh = t.earnings_history + eps_trend: List[Dict[str, Any]] = [] + if eh is not None and hasattr(eh, "iterrows"): + for idx2, row2 in eh.iterrows(): + eps_trend.append({ + "date": str(idx2)[:10], + "surprise_pct": round(_safe_float(row2.get("surprisePercent", 0), 0) * 100, 2), + }) + eps_trend = eps_trend[-4:] + + # AI summary via Gemini (best-effort) + ai_summary: Optional[str] = None + try: + from server.services.gemini_service import generate_text + prompt = ( + f"Compare {ticker.upper()} most recent two quarters.\n" + f"Latest quarter ({latest['period']}): Revenue ${latest['revenue']}, Earnings ${latest['earnings']}.\n" + f"Previous quarter ({prev['period']}): Revenue ${prev['revenue']}, Earnings ${prev['earnings']}.\n" + f"Revenue changed {rev_delta}%, Earnings changed {earn_delta}%.\n" + "In 2-3 sentences, explain what changed and why. Be concise and specific." + ) + ai_summary = await generate_text(prompt) + except Exception: + pass + + return { + "ticker": ticker.upper(), + "available": True, + "latest_quarter": latest["period"], + "prev_quarter": prev["period"], + "latest_revenue": latest["revenue"], + "prev_revenue": prev["revenue"], + "latest_earnings": latest["earnings"], + "prev_earnings": prev["earnings"], + "revenue_delta_pct": rev_delta, + "earnings_delta_pct": earn_delta, + "eps_trend": eps_trend, + "ai_summary": ai_summary, + } + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Earnings delta failed: {exc}") from exc + + @router.get("/{ticker}/quarterly", summary="Quarterly earnings data") async def quarterly_earnings(ticker: str) -> Dict[str, Any]: try: diff --git a/atlas-terminal/server/routers/macro.py b/atlas-terminal/server/routers/macro.py index 7b9ceea..654b20b 100644 --- a/atlas-terminal/server/routers/macro.py +++ b/atlas-terminal/server/routers/macro.py @@ -53,6 +53,22 @@ async def macro_smart_money() -> Dict[str, Any]: } +@router.get("/subfactors", summary="4-category macro subfactor breakdown + cycle stage") +async def macro_subfactors() -> Dict[str, Any]: + from server.services.macro_cycle import get_subfactor_breakdown + + try: + return await asyncio.to_thread(get_subfactor_breakdown) + except Exception as exc: + return { + "updated_at": None, + "composite_score": 0.0, + "cycle_stage": "Unknown", + "categories": {}, + "error": str(exc), + } + + @router.get("/fred/{series_id}", summary="FRED time series (public CSV)") async def macro_fred( series_id: str, @@ -126,7 +142,7 @@ async def macro_economic_calendar( @router.get("/ecos", summary="Korea Bank ECOS (requires ECOS_API_KEY)") async def macro_ecos( - stat_code: str = Query(..., description="ECOS 통계표 코드"), + stat_code: str = Query(..., description="ECOS statistics table code"), cycle: str = Query("M", description="D/W/M/Q/S/Y"), start_ym: str = Query("201501"), end_ym: Optional[str] = Query(None), diff --git a/atlas-terminal/server/routers/market_data.py b/atlas-terminal/server/routers/market_data.py index b2050ec..889340f 100644 --- a/atlas-terminal/server/routers/market_data.py +++ b/atlas-terminal/server/routers/market_data.py @@ -143,11 +143,20 @@ async def sector_industry(ticker: str): "industry": info.get("industry", "N/A"), "market_cap": _safe_float(info.get("marketCap")), "pe_ratio": _safe_float(info.get("trailingPE")) or _safe_float(info.get("forwardPE")), + "forward_pe": _safe_float(info.get("forwardPE")), "dividend_yield": _safe_float(info.get("dividendYield")), "beta": _safe_float(info.get("beta")), "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")), + "target_mean_price": _safe_float(info.get("targetMeanPrice")), + "target_high_price": _safe_float(info.get("targetHighPrice")), + "target_low_price": _safe_float(info.get("targetLowPrice")), + "recommendation": info.get("recommendationKey"), + "analyst_count": info.get("numberOfAnalystOpinions"), + "forward_eps": _safe_float(info.get("forwardEps")), + "trailing_eps": _safe_float(info.get("trailingEps")), + "peg_ratio": _safe_float(info.get("pegRatio")), "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"), diff --git a/atlas-terminal/server/routers/screener.py b/atlas-terminal/server/routers/screener.py index 3ed5113..fddd0e9 100644 --- a/atlas-terminal/server/routers/screener.py +++ b/atlas-terminal/server/routers/screener.py @@ -35,3 +35,28 @@ async def backtest(body: dict): ) except Exception as e: return {"error": str(e)} + + +@router.post("/portfolio-backtest") +async def portfolio_backtest(body: dict): + """Run multi-asset portfolio backtest with rebalancing.""" + try: + from server.services.backtester import run_portfolio_backtest + + tickers = body.get("tickers", []) + weights = body.get("weights", []) + if not tickers: + return {"error": "At least one ticker is required"} + if not weights: + weights = [1.0 / len(tickers)] * len(tickers) + + return await run_portfolio_backtest( + tickers=tickers, + weights=[float(w) for w in weights], + start_date=body.get("start_date", "2021-01-01"), + end_date=body.get("end_date", "2026-01-01"), + rebalance_months=int(body.get("rebalance_months", 3)), + benchmark_ticker=str(body.get("benchmark_ticker") or "SPY"), + ) + except Exception as e: + return {"error": str(e)} diff --git a/atlas-terminal/server/services/backtester.py b/atlas-terminal/server/services/backtester.py index eb3a0b0..043e181 100644 --- a/atlas-terminal/server/services/backtester.py +++ b/atlas-terminal/server/services/backtester.py @@ -110,6 +110,157 @@ def _run_backtest_impl( } +def _run_portfolio_backtest_impl( + tickers: list[str], + weights: list[float], + start_date: str, + end_date: str, + rebalance_months: int = 3, + benchmark_ticker: str = "SPY", +) -> Dict[str, Any]: + """Multi-asset portfolio backtest with periodic rebalancing.""" + import numpy as np + import pandas as pd + import yfinance as yf + + if len(tickers) != len(weights) or not tickers: + return {"error": "Tickers and weights must be non-empty and same length"} + + # Normalize weights + total_w = sum(weights) + if total_w <= 0: + return {"error": "Weights must sum to a positive number"} + norm_weights = [w / total_w for w in weights] + + # Fetch price data + price_frames = {} + for t in tickers: + hist = yf.Ticker(t.upper()).history(start=start_date, end=end_date, auto_adjust=True) + if hist is not None and not hist.empty and "Close" in hist: + price_frames[t.upper()] = hist["Close"] + if not price_frames: + return {"error": "No price data for any ticker"} + + prices = pd.DataFrame(price_frames).dropna() + if len(prices) < 5: + return {"error": "Insufficient overlapping price data"} + + # Benchmark + bm_sym = (benchmark_ticker or "SPY").upper() + bm_hist = yf.Ticker(bm_sym).history(start=start_date, end=end_date, auto_adjust=True) + if bm_hist is None or bm_hist.empty: + return {"error": f"No benchmark data for {bm_sym}"} + + common = prices.index.intersection(bm_hist.index) + if len(common) < 5: + return {"error": "Insufficient overlap with benchmark"} + prices = prices.loc[common] + bm_close = bm_hist.loc[common, "Close"] + + returns = prices.pct_change().fillna(0) + bm_returns = bm_close.pct_change().fillna(0) + + # Map tickers to weights (use only tickers that have data) + avail_tickers = list(prices.columns) + ticker_weight = {} + for t, w in zip(tickers, norm_weights): + tu = t.upper() + if tu in avail_tickers: + ticker_weight[tu] = w + # Re-normalize + tw_sum = sum(ticker_weight.values()) + if tw_sum <= 0: + return {"error": "No valid tickers with data"} + for k in ticker_weight: + ticker_weight[k] /= tw_sum + + # Rebalancing: compute portfolio returns + current_weights = {t: ticker_weight[t] for t in ticker_weight} + portfolio_returns = [] + last_rebal = None + + for i, dt in enumerate(prices.index): + if i == 0: + portfolio_returns.append(0.0) + last_rebal = dt + continue + + # Daily portfolio return = sum of weight * return + daily_ret = sum(current_weights.get(t, 0) * returns.loc[dt, t] for t in avail_tickers if t in current_weights) + portfolio_returns.append(daily_ret) + + # Drift weights + for t in current_weights: + current_weights[t] *= (1 + returns.loc[dt, t]) + w_sum = sum(current_weights.values()) + if w_sum > 0: + for t in current_weights: + current_weights[t] /= w_sum + + # Rebalance check + if last_rebal is not None and _months_between(last_rebal, dt) >= rebalance_months: + current_weights = {t: ticker_weight[t] for t in ticker_weight} + last_rebal = dt + + port_ret = pd.Series(portfolio_returns, index=prices.index) + cumulative = (1 + port_ret).cumprod() + benchmark_cum = (1 + bm_returns).cumprod() + + # Metrics + total_ret = round((float(cumulative.iloc[-1]) - 1) * 100, 2) + bm_ret = round((float(benchmark_cum.iloc[-1]) - 1) * 100, 2) + mdd = round(float(((cumulative / cumulative.cummax()) - 1).min()) * 100, 2) + sharpe = round(float(port_ret.mean() / (port_ret.std() + 1e-10) * (252**0.5)), 2) + + # Sortino + downside = port_ret[port_ret < 0] + sortino = round(float(port_ret.mean() / (downside.std() + 1e-10) * (252**0.5)), 2) if len(downside) > 0 else 0.0 + + # Contribution per ticker + contributions = {} + for t in ticker_weight: + t_ret = returns[t] + contrib = float((t_ret * ticker_weight[t]).sum()) * 100 + contributions[t] = round(contrib, 2) + + return { + "tickers": list(ticker_weight.keys()), + "weights": {t: round(w, 4) for t, w in ticker_weight.items()}, + "benchmark_ticker": bm_sym, + "total_return_pct": total_ret, + "benchmark_return_pct": bm_ret, + "alpha": round(total_ret - bm_ret, 2), + "max_drawdown_pct": mdd, + "sharpe_ratio": sharpe, + "sortino_ratio": sortino, + "rebalance_months": rebalance_months, + "contributions": contributions, + "equity_curve": [round(float(x), 4) for x in cumulative.tolist()], + "benchmark_curve": [round(float(x), 4) for x in benchmark_cum.tolist()], + "dates": prices.index.strftime("%Y-%m-%d").tolist(), + } + + +async def run_portfolio_backtest( + tickers: list[str], + weights: list[float], + start_date: str, + end_date: str, + rebalance_months: int = 3, + benchmark_ticker: str = "SPY", +) -> dict: + """Run a multi-asset portfolio backtest with periodic rebalancing.""" + return await asyncio.to_thread( + _run_portfolio_backtest_impl, + tickers, + weights, + start_date, + end_date, + rebalance_months, + benchmark_ticker, + ) + + async def run_backtest( ticker: str, strategy: str, diff --git a/atlas-terminal/server/services/macro_cycle.py b/atlas-terminal/server/services/macro_cycle.py index cccd8ca..a79b1f7 100644 --- a/atlas-terminal/server/services/macro_cycle.py +++ b/atlas-terminal/server/services/macro_cycle.py @@ -393,6 +393,142 @@ def get_macro_cycle_snapshot() -> Dict[str, Any]: } +_SUBFACTOR_CATEGORIES: Dict[str, List[MacroSeries]] = { + "Growth": [ + MacroSeries("gdp_growth", "Real GDP Growth", "A191RL1Q225SBEA", "quarterly", "%", True), + MacroSeries("ism_pmi", "ISM Manufacturing PMI", "MANEMP", "monthly", "idx", True, is_index=False), + MacroSeries("industrial_prod", "Industrial Production", "INDPRO", "monthly", "%", True, is_index=True), + MacroSeries("retail_sales", "Retail Sales", "RSXFS", "monthly", "%", True, is_index=True), + ], + "Prices": [ + MacroSeries("cpi_yoy", "CPI YoY", "CPIAUCSL", "monthly", "%", False, is_index=True), + MacroSeries("core_cpi", "Core CPI", "CPILFESL", "monthly", "%", False, is_index=True), + MacroSeries("ppi", "PPI", "PPIACO", "monthly", "%", False, is_index=True), + MacroSeries("pce", "PCE Price Index", "PCEPI", "monthly", "%", False, is_index=True), + ], + "Labor": [ + MacroSeries("unemployment", "Unemployment Rate", "UNRATE", "monthly", "%", False), + MacroSeries("nonfarm", "Nonfarm Payrolls", "PAYEMS", "monthly", "K", True, is_index=False), + MacroSeries("initial_claims", "Initial Claims", "ICSA", "weekly", "K", False), + MacroSeries("participation", "Participation Rate", "CIVPART", "monthly", "%", True), + ], + "Financial": [ + MacroSeries("yield_spread", "10Y-2Y Spread", "T10Y2Y", "daily", "bp", True), + MacroSeries("vix", "VIX", "VIXCLS", "daily", "idx", False), + MacroSeries("credit_spread", "BAA-AAA Spread", "BAAFFM", "monthly", "bp", False), + MacroSeries("fed_funds", "Fed Funds Rate", "FEDFUNDS", "monthly", "%", False), + ], +} + + +def _subfactor_3m_change(series) -> Optional[float]: + """Compute 3-month change from a pandas Series.""" + if series is None or len(series) < 4: + return None + try: + recent = float(series.iloc[-1]) + past = float(series.iloc[-4]) if len(series) >= 4 else float(series.iloc[0]) + if past == 0: + return None + return round((recent - past) / abs(past) * 100, 2) + except Exception: + return None + + +def _subfactor_signal(zscore: Optional[float], change_3m: Optional[float], higher_is_better: bool) -> str: + """Return improving / neutral / deteriorating.""" + if change_3m is None and zscore is None: + return "neutral" + if change_3m is not None: + effective = change_3m if higher_is_better else -change_3m + if effective > 1.5: + return "improving" + if effective < -1.5: + return "deteriorating" + if zscore is not None: + effective_z = zscore if higher_is_better else -zscore + if effective_z > 0.5: + return "improving" + if effective_z < -0.5: + return "deteriorating" + return "neutral" + + +_cached_subfactors = cached("macro_subfactors", ttl_seconds=3600) + + +@_cached_subfactors +def get_subfactor_breakdown() -> Dict[str, Any]: + """Return 4-category × 4-indicator subfactor breakdown with cycle stage.""" + categories: Dict[str, Any] = {} + all_scores: List[float] = [] + + for cat_name, indicators in _SUBFACTOR_CATEGORIES.items(): + items: List[Dict[str, Any]] = [] + cat_scores: List[float] = [] + + for s in indicators: + try: + raw = _fetch_fred_series(s.fred_code) + if raw is None or raw.empty: + items.append({"key": s.key, "label": s.label, "value": None, "change_3m": None, "zscore": None, "signal": "neutral"}) + continue + values = raw.iloc[:, 0] + if s.is_index: + values = _series_to_pct_change(values) + values = values.dropna() if values is not None else values + if values is None or values.empty: + items.append({"key": s.key, "label": s.label, "value": None, "change_3m": None, "zscore": None, "signal": "neutral"}) + continue + + latest = _safe_float(values.iloc[-1]) + z = _zscore([float(v) for v in values.tail(60).tolist() if _safe_float(v) is not None]) + change = _subfactor_3m_change(values) + signal = _subfactor_signal(z, change, s.higher_is_better) + + if z is not None: + effective = z if s.higher_is_better else -z + cat_scores.append(effective) + all_scores.append(effective) + + items.append({ + "key": s.key, + "label": s.label, + "value": round(latest, 2) if latest is not None else None, + "unit": s.unit, + "change_3m": change, + "zscore": round(z, 2) if z is not None else None, + "signal": signal, + }) + except Exception: + items.append({"key": s.key, "label": s.label, "value": None, "change_3m": None, "zscore": None, "signal": "neutral"}) + + cat_score = round(float(np.mean(cat_scores)), 2) if cat_scores else 0.0 + categories[cat_name] = {"score": cat_score, "indicators": items} + + # Determine cycle stage from composite score + composite = round(float(np.mean(all_scores)), 2) if all_scores else 0.0 + growth_score = categories.get("Growth", {}).get("score", 0) + price_score = categories.get("Prices", {}).get("score", 0) + + # 4-stage cycle: growth momentum + price momentum + if growth_score > 0 and price_score <= 0: + stage = "Early Expansion" + elif growth_score > 0 and price_score > 0: + stage = "Late Expansion" + elif growth_score <= 0 and price_score > 0: + stage = "Early Contraction" + else: + stage = "Late Contraction" + + return { + "updated_at": datetime.utcnow().isoformat() + "Z", + "composite_score": composite, + "cycle_stage": stage, + "categories": categories, + } + + def get_country_series(country: str, indicator: str, period: str = "5y") -> Dict[str, Any]: """Return a time series for a single country/indicator pair.""" mappings = COUNTRY_SERIES.get(country)