From ef429e93c4f939750bbad33a47b153cc931f3173 Mon Sep 17 00:00:00 2001 From: shawnkim1997 Date: Sun, 10 May 2026 23:56:27 +0100 Subject: [PATCH] feat: daily news workbench and Korean-aware ticker search - /daily-news route fetches FT ePaper headlines with Gemini-backed Korean translation, plus calendar selector and cached translation TTL - /api/search and use-ticker-search wire async sidebar search through a unified backend that merges static aliases with the local KOSPI/KOSDAQ universe (162 names) and yfinance metadata - ticker-alias gains Korean display names, currency and market hints; PeerComparison formats KRW/JPY with locale-aware zero decimals - EquityOverview surfaces HQ city/country, with Korean Naver snapshot fallback when yfinance info is empty - market_data adds /korean-universe/search for autocomplete - New tests for FT ingestion, Korean universe lookups, and updated smoke prefixes --- .../daily-news/DailyNewsCalendar.tsx | 112 ++++ .../components/daily-news/HeadlineCard.tsx | 101 +++ .../components/overview/EquityOverview.tsx | 160 ++++- .../components/overview/PeerComparison.tsx | 27 +- .../apps/web/src/app/components/sidebar.tsx | 60 +- .../apps/web/src/app/daily-news/page.tsx | 156 +++++ .../apps/web/src/app/lib/daily-news-types.ts | 15 + .../apps/web/src/app/lib/daily-news-utils.ts | 37 ++ .../apps/web/src/app/lib/ticker-alias.ts | 188 +++++- .../apps/web/src/app/lib/use-daily-news.ts | 114 ++++ .../apps/web/src/app/lib/use-ticker-search.ts | 148 +++++ atlas-terminal/server/main.py | 33 +- atlas-terminal/server/models/schemas.py | 15 +- atlas-terminal/server/routers/daily_news.py | 47 ++ atlas-terminal/server/routers/market_data.py | 102 ++- atlas-terminal/server/routers/search.py | 20 + .../server/services/etf_analysis.py | 68 +- .../server/services/ft_epaper_service.py | 261 ++++++++ .../server/services/korean_market.py | 623 ++++++++++++++++++ .../server/services/korean_stock_universe.py | 162 +++++ .../tests/test_ft_epaper_service.py | 140 ++++ .../tests/test_korean_stock_universe.py | 67 ++ atlas-terminal/tests/test_smoke.py | 1 + 23 files changed, 2582 insertions(+), 75 deletions(-) create mode 100644 atlas-terminal/apps/web/src/app/components/daily-news/DailyNewsCalendar.tsx create mode 100644 atlas-terminal/apps/web/src/app/components/daily-news/HeadlineCard.tsx create mode 100644 atlas-terminal/apps/web/src/app/daily-news/page.tsx create mode 100644 atlas-terminal/apps/web/src/app/lib/daily-news-types.ts create mode 100644 atlas-terminal/apps/web/src/app/lib/daily-news-utils.ts create mode 100644 atlas-terminal/apps/web/src/app/lib/use-daily-news.ts create mode 100644 atlas-terminal/apps/web/src/app/lib/use-ticker-search.ts create mode 100644 atlas-terminal/server/routers/daily_news.py create mode 100644 atlas-terminal/server/routers/search.py create mode 100644 atlas-terminal/server/services/ft_epaper_service.py create mode 100644 atlas-terminal/server/services/korean_market.py create mode 100644 atlas-terminal/server/services/korean_stock_universe.py create mode 100644 atlas-terminal/tests/test_ft_epaper_service.py create mode 100644 atlas-terminal/tests/test_korean_stock_universe.py diff --git a/atlas-terminal/apps/web/src/app/components/daily-news/DailyNewsCalendar.tsx b/atlas-terminal/apps/web/src/app/components/daily-news/DailyNewsCalendar.tsx new file mode 100644 index 0000000..21030ce --- /dev/null +++ b/atlas-terminal/apps/web/src/app/components/daily-news/DailyNewsCalendar.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { dateKey, isSupportedDailyNewsDate, parseDateKey, todayKey } from "../../lib/daily-news-utils"; + +interface DailyNewsCalendarProps { + selectedDate: string; + readDates: Set; + bookmarkedDates: Set; + onSelectDate: (value: string) => void; + onUnsupportedDate: (message: string) => void; +} + +const DAY_LABELS = ["S", "M", "T", "W", "T", "F", "S"]; + +function monthGrid(monthKey: string): (Date | null)[] { + const month = parseDateKey(monthKey); + const first = new Date(month.getFullYear(), month.getMonth(), 1); + const last = new Date(month.getFullYear(), month.getMonth() + 1, 0); + const cells: (Date | null)[] = []; + + for (let i = 0; i < first.getDay(); i += 1) cells.push(null); + for (let day = 1; day <= last.getDate(); day += 1) { + cells.push(new Date(month.getFullYear(), month.getMonth(), day)); + } + while (cells.length % 7 !== 0) cells.push(null); + return cells; +} + +export function DailyNewsCalendar({ + selectedDate, + readDates, + bookmarkedDates, + onSelectDate, + onUnsupportedDate, +}: DailyNewsCalendarProps) { + const [visibleMonth, setVisibleMonth] = useState(selectedDate); + const today = todayKey(); + const cells = useMemo(() => monthGrid(visibleMonth), [visibleMonth]); + + useEffect(() => { + setVisibleMonth(selectedDate); + }, [selectedDate]); + + function shiftMonth(direction: number) { + const current = parseDateKey(visibleMonth); + setVisibleMonth(dateKey(new Date(current.getFullYear(), current.getMonth() + direction, 1))); + } + + return ( +
+
+ +
+ {parseDateKey(visibleMonth).toLocaleDateString(undefined, { month: "long", year: "numeric" })} +
+ +
+ +
+ {DAY_LABELS.map((label, index) =>
{label}
)} +
+ +
+ {cells.map((cell, index) => { + if (!cell) { + return
; + } + + const key = dateKey(cell); + const selected = key === selectedDate; + const isToday = key === today; + const supported = isSupportedDailyNewsDate(key); + const read = readDates.has(key); + const bookmarked = bookmarkedDates.has(key); + + return ( + + ); + })} +
+
+ ); +} diff --git a/atlas-terminal/apps/web/src/app/components/daily-news/HeadlineCard.tsx b/atlas-terminal/apps/web/src/app/components/daily-news/HeadlineCard.tsx new file mode 100644 index 0000000..f80f053 --- /dev/null +++ b/atlas-terminal/apps/web/src/app/components/daily-news/HeadlineCard.tsx @@ -0,0 +1,101 @@ +"use client"; + +import Image from "next/image"; + +import type { FTHeadline } from "../../lib/daily-news-types"; + +interface HeadlineCardProps { + headline: FTHeadline; + isRead: boolean; + isBookmarked: boolean; + onToggleRead: () => void; + onToggleBookmark: () => void; +} + +export function HeadlineCard({ + headline, + isRead, + isBookmarked, + onToggleRead, + onToggleBookmark, +}: HeadlineCardProps) { + const displayTitle = headline.title_ko || headline.title_en; + const displayLede = headline.lede_ko || headline.lede_en || "Preview unavailable. Open the original FT article for the full story."; + + return ( +
+
+
+ {headline.image ? ( + {headline.title_en} + ) : ( +
+ + {headline.section || "FT"} + +
+ )} +
+ +
+
+ + {headline.section || "Financial Times"} + + + {new Date(headline.published_at).toLocaleString()} + +
+ +

{displayTitle}

+

{headline.title_en}

+

+ {displayLede} +

+ +
+ + FT에서 원문 열기 ↗ + + + +
+
+
+
+ ); +} 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 0f97723..29f0b12 100644 --- a/atlas-terminal/apps/web/src/app/components/overview/EquityOverview.tsx +++ b/atlas-terminal/apps/web/src/app/components/overview/EquityOverview.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { KpiSection, type KpiHistoryData } from "./KpiSection"; import { PeerComparison, type PeerComparisonData } from "./PeerComparison"; import { FinancialStatements } from "./FinancialStatements"; @@ -9,6 +9,7 @@ import { RedTeamCritique } from "./RedTeamCritique"; import { Card } from "../ui/Card"; import { SectionHeading } from "../ui/SectionHeading"; import { StatCard } from "../ui/StatCard"; +import { useApi } from "../../lib/use-api"; interface EquityOverviewProps { ticker: string; @@ -16,9 +17,121 @@ interface EquityOverviewProps { health: Record | null; } +interface FxRateMatrix { + rates?: Record; +} + +const ZERO_DECIMAL_CURRENCIES = new Set(["KRW", "JPY"]); +const CURRENCY_LOCALES: Record = { + KRW: "ko-KR", + JPY: "ja-JP", + USD: "en-US", + EUR: "de-DE", + GBP: "en-GB", + DKK: "da-DK", +}; + +function getCurrencyCode(ticker: string, sector: Record | null): string { + const raw = typeof sector?.currency === "string" ? sector.currency.trim().toUpperCase() : ""; + if (raw) return raw; + if (ticker.endsWith(".KS") || ticker.endsWith(".KQ")) return "KRW"; + if (ticker.endsWith(".T")) return "JPY"; + return "USD"; +} + +function currencyLocale(currency: string): string { + return CURRENCY_LOCALES[currency] || "en-US"; +} + +function formatCurrencyValue(value: number, currency: string, fractionDigits?: number): string { + const digits = fractionDigits ?? (ZERO_DECIMAL_CURRENCIES.has(currency) ? 0 : 2); + return new Intl.NumberFormat(currencyLocale(currency), { + style: "currency", + currency, + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }).format(value); +} + +function formatCompactCurrency(value: number, currency: string): string { + return new Intl.NumberFormat(currencyLocale(currency), { + style: "currency", + currency, + notation: "compact", + minimumFractionDigits: 0, + maximumFractionDigits: 1, + }).format(value); +} + +function formatUsdEquivalent(value: number, currency: string, rates?: Record, compact = false): string | null { + if (currency === "USD") return null; + const rate = rates?.[`${currency}_USD`]; + if (typeof rate !== "number" || !Number.isFinite(rate) || rate <= 0) return null; + const usdValue = value * rate; + return compact + ? formatCompactCurrency(usdValue, "USD") + : formatCurrencyValue(usdValue, "USD", ZERO_DECIMAL_CURRENCIES.has(currency) ? 0 : 2); +} + +function moneyDisplay( + value: number | null | undefined, + currency: string, + rates?: Record, + opts: { compact?: boolean } = {}, +): { primary: string; secondary: string | null } { + if (value == null || !Number.isFinite(value)) { + return { primary: "—", secondary: null }; + } + const primary = opts.compact ? formatCompactCurrency(value, currency) : formatCurrencyValue(value, currency); + const usd = formatUsdEquivalent(value, currency, rates, opts.compact === true); + return { primary, secondary: usd ? `= ${usd}` : null }; +} + +function splitLeadingCurrency(display: string, currency: string): { symbol: string; value: string } | null { + const knownSymbols: Record = { + USD: "$", + EUR: "€", + GBP: "£", + JPY: "¥", + KRW: "₩", + }; + const symbol = knownSymbols[currency]; + if (!symbol || !display.startsWith(symbol)) return null; + return { symbol, value: display.slice(symbol.length) }; +} + +function MoneyValue({ + display, + currency, + emphasis = "card", +}: { + display: string; + currency: string; + emphasis?: "hero" | "card" | "label"; +}) { + const parts = splitLeadingCurrency(display, currency); + if (!parts) return <>{display}; + + const symbolClass = + emphasis === "hero" + ? "mr-1 align-top text-[0.56em] font-semibold text-text-secondary" + : emphasis === "label" + ? "mr-0.5 align-top text-[0.7em] font-semibold text-text-muted" + : "mr-0.5 align-top text-[0.68em] font-semibold text-text-muted"; + + return ( + + {parts.symbol} + {parts.value} + + ); +} + export function EquityOverview({ ticker, sector, health }: EquityOverviewProps) { const [peerData, setPeerData] = useState(null); const [kpiData, setKpiData] = useState(null); + const fx = useApi("/api/fx/rates", { cacheTtlMs: 300_000 }); + const currency = getCurrencyCode(ticker, sector); useEffect(() => { let cancelled = false; @@ -35,17 +148,22 @@ export function EquityOverview({ ticker, sector, health }: EquityOverviewProps) cancelled = true; }; }, [ticker]); - const metrics: { label: string; value: string }[] = [ + const marketCap = moneyDisplay(sector?.market_cap != null ? Number(sector.market_cap) : null, currency, fx.data?.rates, { compact: true }); + const high52w = moneyDisplay(sector?.fifty_two_week_high != null ? Number(sector.fifty_two_week_high) : null, currency, fx.data?.rates); + const low52w = moneyDisplay(sector?.fifty_two_week_low != null ? Number(sector.fifty_two_week_low) : null, currency, fx.data?.rates); + const spot = moneyDisplay(sector?.current_price != null ? Number(sector.current_price) : null, currency, fx.data?.rates); + + const metrics: { label: string; value: ReactNode; detail?: string }[] = [ { 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: "Market Cap", value: , detail: marketCap.secondary || undefined }, { 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)}` : "—" }, - { label: "52W Low", value: sector?.fifty_two_week_low != null ? `$${Number(sector.fifty_two_week_low).toFixed(2)}` : "—" }, + { label: "52W High", value: , detail: high52w.secondary || undefined }, + { label: "52W Low", value: , detail: low52w.secondary || undefined }, ]; return ( @@ -55,14 +173,19 @@ export function EquityOverview({ ticker, sector, health }: EquityOverviewProps) {sector?.current_price != null && ( -

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

+
+

+ +

+ {spot.secondary &&

{spot.secondary}

} +
)}
{metrics.map((m) => ( - + ))}
- +
@@ -96,7 +219,16 @@ export function EquityOverview({ ticker, sector, health }: EquityOverviewProps) ); } -function ConsensusGauge({ sector }: { sector: Record | null }) { +function ConsensusGauge({ + ticker, + sector, + rates, +}: { + ticker: string; + sector: Record | null; + rates?: Record; +}) { + const currency = getCurrencyCode(ticker, sector); 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; @@ -116,6 +248,7 @@ function ConsensusGauge({ sector }: { sector: Record | null }) 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; + const targetDisplay = moneyDisplay(target, currency, rates); return ( @@ -125,7 +258,10 @@ function ConsensusGauge({ sector }: { sector: Record | null })
Target - ${target.toFixed(2)} + + + + {targetDisplay.secondary && {targetDisplay.secondary}}
Upside @@ -146,8 +282,8 @@ function ConsensusGauge({ sector }: { sector: Record | null })
- ${gaugeLow.toFixed(0)} - ${gaugeHigh.toFixed(0)} + +
); diff --git a/atlas-terminal/apps/web/src/app/components/overview/PeerComparison.tsx b/atlas-terminal/apps/web/src/app/components/overview/PeerComparison.tsx index 5dc2a0d..df41494 100644 --- a/atlas-terminal/apps/web/src/app/components/overview/PeerComparison.tsx +++ b/atlas-terminal/apps/web/src/app/components/overview/PeerComparison.tsx @@ -43,6 +43,31 @@ const METRIC_LABELS: Record = { }; const LOWER_IS_BETTER = new Set(["pe", "ev_ebitda"]); +const ZERO_DECIMAL_CURRENCIES = new Set(["KRW", "JPY"]); +const CURRENCY_LOCALES: Record = { + KRW: "ko-KR", + JPY: "ja-JP", + USD: "en-US", +}; + +function currencyForTicker(ticker: string): string { + const normalized = ticker.toUpperCase(); + if (normalized.endsWith(".KS") || normalized.endsWith(".KQ")) return "KRW"; + if (normalized.endsWith(".T")) return "JPY"; + return "USD"; +} + +function formatMarketCap(value: number | null | undefined, ticker: string): string { + if (value == null) return "—"; + const currency = currencyForTicker(ticker); + return new Intl.NumberFormat(CURRENCY_LOCALES[currency] || "en-US", { + style: "currency", + currency, + notation: "compact", + minimumFractionDigits: 0, + maximumFractionDigits: ZERO_DECIMAL_CURRENCIES.has(currency) ? 1 : 2, + }).format(value); +} function formatValue(value: number | null | undefined, type: "multiple" | "marketCap" | "percent" = "multiple"): string { if (value == null) return "—"; @@ -142,7 +167,7 @@ export function PeerComparison({ {peer.name} - {formatValue(peer.market_cap, "marketCap")} + {formatMarketCap(peer.market_cap, peer.ticker)} {metrics.map((metric) => { const value = peer[metric]; diff --git a/atlas-terminal/apps/web/src/app/components/sidebar.tsx b/atlas-terminal/apps/web/src/app/components/sidebar.tsx index 8dad20f..df7cc0c 100644 --- a/atlas-terminal/apps/web/src/app/components/sidebar.tsx +++ b/atlas-terminal/apps/web/src/app/components/sidebar.tsx @@ -1,10 +1,11 @@ "use client"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { BarChart3, Briefcase, CalendarDays, CalendarRange, FileSearch, FileText, FileVideo, Globe, Landmark, LineChart, Microscope, Newspaper, Search, Settings, Sparkles, Target, TrendingUp } from "lucide-react"; -import { useMemo, useState } from "react"; +import { BarChart3, BookOpen, Briefcase, CalendarDays, CalendarRange, FileSearch, FileText, FileVideo, Globe, Landmark, LineChart, LoaderCircle, Microscope, Newspaper, Search, Settings, Sparkles, Target, TrendingUp } from "lucide-react"; +import { useEffect, useState } from "react"; import { flags } from "../lib/flags"; -import { searchTickerSuggestions, type TickerSuggestion } from "../lib/ticker-alias"; +import { type TickerSuggestion } from "../lib/ticker-alias"; +import { useTickerSearch } from "../lib/use-ticker-search"; import { useTicker } from "../lib/use-ticker"; const NAV_ITEMS = [ @@ -17,6 +18,7 @@ const NAV_ITEMS = [ { href: "/earnings", label: "Earnings", icon: CalendarRange }, ...(flags.calendar ? [{ href: "/calendar", label: "Calendar", icon: CalendarDays }] : []), { href: "/news", label: "News", icon: Newspaper }, + { href: "/daily-news", label: "Daily News", icon: BookOpen }, { href: "/transcripts", label: "Transcripts", icon: FileVideo }, { href: "/screener", label: "Screener", icon: Target }, { href: "/portfolio", label: "Portfolio", icon: Briefcase }, @@ -29,9 +31,15 @@ export function Sidebar() { const [input, setInput] = useState(""); const [highlightedIndex, setHighlightedIndex] = useState(0); const { ticker, setTicker } = useTicker(); - const suggestions = useMemo(() => searchTickerSuggestions(input, 5), [input]); + const { suggestions, loading, source } = useTickerSearch(input, 6); const showSuggestions = input.trim().length > 0 && suggestions.length > 0; + useEffect(() => { + if (highlightedIndex >= suggestions.length) { + setHighlightedIndex(0); + } + }, [highlightedIndex, suggestions.length]); + function selectSuggestion(suggestion: TickerSuggestion) { setTicker(suggestion.ticker); setInput(""); @@ -108,6 +116,7 @@ export function Sidebar() { aria-autocomplete="list" className="w-full border-none bg-transparent text-sm text-text-primary outline-none" /> + {loading && }
{showSuggestions && (
@@ -130,19 +139,50 @@ export function Sidebar() { >
{suggestion.ticker} - - {suggestion.exchange} - +
+ {suggestion.country && ( + + {suggestion.country} + + )} + + {suggestion.exchange} + +
{suggestion.name}
+ {(suggestion.nameKo || suggestion.market || suggestion.currency) && ( +
+ {suggestion.nameKo && {suggestion.nameKo}} + {suggestion.market && ( + + {suggestion.market} + + )} + {suggestion.currency && ( + + {suggestion.currency} + + )} +
+ )} ); })} +
+ {source === "remote" ? "Live search results" : "Local desk search fallback"} +
)} - {input.trim().length > 0 && suggestions.length === 0 && ( + {input.trim().length > 0 && suggestions.length === 0 && !loading && (
No match yet. Press Enter to use {input.trim().toUpperCase()}.
diff --git a/atlas-terminal/apps/web/src/app/daily-news/page.tsx b/atlas-terminal/apps/web/src/app/daily-news/page.tsx new file mode 100644 index 0000000..ff9a631 --- /dev/null +++ b/atlas-terminal/apps/web/src/app/daily-news/page.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { HeadlineCard } from "../components/daily-news/HeadlineCard"; +import { DailyNewsCalendar } from "../components/daily-news/DailyNewsCalendar"; +import { ErrorBanner } from "../components/ui/ErrorBanner"; +import { LoadingPulse } from "../components/ui/LoadingPulse"; +import { SectionHeading } from "../components/ui/SectionHeading"; +import type { FTHeadline } from "../lib/daily-news-types"; +import { addDays, formatNewsHeading, isSupportedDailyNewsDate, todayKey } from "../lib/daily-news-utils"; +import { useBookmarks, useReadStatus } from "../lib/use-daily-news"; + +export default function DailyNewsPage() { + const [selectedDate, setSelectedDate] = useState(todayKey()); + const [headlines, setHeadlines] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [todayLink, setTodayLink] = useState("https://www.ft.com/todaysnewspaper/international"); + const [hasGeminiKey, setHasGeminiKey] = useState(false); + const { readDates, isRead, toggleRead } = useReadStatus(); + const { bookmarkedDates, isBookmarked, toggleBookmark } = useBookmarks(); + + useEffect(() => { + const apiKey = localStorage.getItem("atlas_gemini_key") || ""; + setHasGeminiKey(Boolean(apiKey.trim())); + + fetch("/api/daily-news/today-link") + .then((response) => (response.ok ? response.json() : null)) + .then((payload) => { + if (payload?.url) setTodayLink(payload.url); + }) + .catch(() => {}); + }, []); + + useEffect(() => { + let cancelled = false; + + if (!isSupportedDailyNewsDate(selectedDate)) { + setHeadlines([]); + setLoading(false); + setNotice("FT RSS currently covers only the most recent 7 days. Use the FT ePaper link for older archive dates."); + return () => { + cancelled = true; + }; + } + + setLoading(true); + setError(null); + const apiKey = localStorage.getItem("atlas_gemini_key") || ""; + setHasGeminiKey(Boolean(apiKey.trim())); + const headers = apiKey.trim() ? { "x-gemini-api-key": apiKey.trim() } : undefined; + fetch(`/api/daily-news/${selectedDate}`, { headers }) + .then(async (response) => { + if (!response.ok) { + const payload = await response.json().catch(() => null); + throw new Error(payload?.detail || "Failed to load daily FT headlines."); + } + return response.json(); + }) + .then((payload: FTHeadline[]) => { + if (cancelled) return; + setHeadlines(Array.isArray(payload) ? payload : []); + setNotice(null); + }) + .catch((fetchError: Error) => { + if (cancelled) return; + setHeadlines([]); + setError(fetchError.message); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [selectedDate]); + + const selectedLabel = useMemo(() => formatNewsHeading(selectedDate), [selectedDate]); + + return ( +
+
+
+ Daily News +

Financial Times headlines with Korean summary and one-click FT open-in-new-tab workflow.

+
+
+ + + + FT ePaper 전체 보기 ↗ + +
+
+ + + + + +
+ + +
+
+
+
+

{selectedLabel}

+

{headlines.length} FT headlines

+
+
+
+ + {loading ? ( + + ) : headlines.length === 0 ? ( +
+ No FT headlines were found for this date. Weekend or RSS coverage gaps can produce empty days. +
+ ) : ( +
+ {headlines.map((headline) => { + const articleDate = headline.published_at.slice(0, 10); + return ( + toggleRead(headline.url, articleDate)} + onToggleBookmark={() => toggleBookmark(headline, articleDate)} + /> + ); + })} +
+ )} +
+
+
+ ); +} diff --git a/atlas-terminal/apps/web/src/app/lib/daily-news-types.ts b/atlas-terminal/apps/web/src/app/lib/daily-news-types.ts new file mode 100644 index 0000000..0e71225 --- /dev/null +++ b/atlas-terminal/apps/web/src/app/lib/daily-news-types.ts @@ -0,0 +1,15 @@ +export interface FTHeadline { + url: string; + title_en: string; + title_ko?: string | null; + lede_en?: string | null; + lede_ko?: string | null; + section?: string | null; + published_at: string; + image?: string | null; +} + +export interface FTBookmark extends FTHeadline { + date_key: string; + saved_at: string; +} diff --git a/atlas-terminal/apps/web/src/app/lib/daily-news-utils.ts b/atlas-terminal/apps/web/src/app/lib/daily-news-utils.ts new file mode 100644 index 0000000..f811e64 --- /dev/null +++ b/atlas-terminal/apps/web/src/app/lib/daily-news-utils.ts @@ -0,0 +1,37 @@ +export function dateKey(value: Date): string { + const year = value.getFullYear(); + const month = `${value.getMonth() + 1}`.padStart(2, "0"); + const day = `${value.getDate()}`.padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +export function parseDateKey(value: string): Date { + const [year, month, day] = value.split("-").map(Number); + return new Date(year, (month || 1) - 1, day || 1); +} + +export function addDays(base: string | Date, amount: number): string { + const next = typeof base === "string" ? parseDateKey(base) : new Date(base); + next.setDate(next.getDate() + amount); + return dateKey(next); +} + +export function todayKey(): string { + return dateKey(new Date()); +} + +export function isSupportedDailyNewsDate(value: string, maxPastDays = 7): boolean { + const target = parseDateKey(value); + const today = parseDateKey(todayKey()); + const delta = Math.round((today.getTime() - target.getTime()) / 86_400_000); + return delta >= 0 && delta <= maxPastDays; +} + +export function formatNewsHeading(value: string): string { + return parseDateKey(value).toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + weekday: "short", + }); +} diff --git a/atlas-terminal/apps/web/src/app/lib/ticker-alias.ts b/atlas-terminal/apps/web/src/app/lib/ticker-alias.ts index b4cd528..c3489e3 100644 --- a/atlas-terminal/apps/web/src/app/lib/ticker-alias.ts +++ b/atlas-terminal/apps/web/src/app/lib/ticker-alias.ts @@ -1,7 +1,10 @@ export type TickerSuggestion = { ticker: string; name: string; + nameKo?: string; exchange: string; + market?: string; + currency?: string; assetType: "Equity" | "ETF" | "Commodity" | "Crypto" | "Index"; country?: string; aliases: string[]; @@ -27,7 +30,10 @@ export const TICKER_DIRECTORY: TickerSuggestion[] = [ { ticker: "000660.KS", name: "SK hynix Inc.", + nameKo: "SK하이닉스", exchange: "KOSPI", + market: "Korea Main Board", + currency: "KRW", assetType: "Equity", country: "KR", aliases: ["sk hynix", "skhynix", "hynix", "sk하이닉스", "sk 하이닉스", "에스케이하이닉스", "에스케이 하이닉스", "하이닉스"], @@ -35,7 +41,10 @@ export const TICKER_DIRECTORY: TickerSuggestion[] = [ { ticker: "005930.KS", name: "Samsung Electronics Co., Ltd.", + nameKo: "삼성전자", exchange: "KOSPI", + market: "Korea Main Board", + currency: "KRW", assetType: "Equity", country: "KR", aliases: ["samsung", "samsung electronics", "삼성전자", "삼성 전자", "삼전"], @@ -43,7 +52,10 @@ export const TICKER_DIRECTORY: TickerSuggestion[] = [ { ticker: "005380.KS", name: "Hyundai Motor Company", + nameKo: "현대자동차", exchange: "KOSPI", + market: "Korea Main Board", + currency: "KRW", assetType: "Equity", country: "KR", aliases: ["hyundai motor", "hyundai motors", "현대차", "현대자동차", "현대 자동차"], @@ -51,7 +63,10 @@ export const TICKER_DIRECTORY: TickerSuggestion[] = [ { ticker: "035420.KS", name: "NAVER Corporation", + nameKo: "네이버", exchange: "KOSPI", + market: "Korea Main Board", + currency: "KRW", assetType: "Equity", country: "KR", aliases: ["naver", "네이버"], @@ -59,11 +74,157 @@ export const TICKER_DIRECTORY: TickerSuggestion[] = [ { ticker: "035720.KS", name: "Kakao Corp.", + nameKo: "카카오", exchange: "KOSPI", + market: "Korea Main Board", + currency: "KRW", assetType: "Equity", country: "KR", aliases: ["kakao", "카카오"], }, + { + ticker: "051910.KS", + name: "LG Chem, Ltd.", + nameKo: "LG화학", + exchange: "KOSPI", + market: "Korea Main Board", + currency: "KRW", + assetType: "Equity", + country: "KR", + aliases: ["lg chem", "lg chemical", "lg화학", "엘지화학"], + }, + { + ticker: "373220.KS", + name: "LG Energy Solution, Ltd.", + nameKo: "LG에너지솔루션", + exchange: "KOSPI", + market: "Korea Main Board", + currency: "KRW", + assetType: "Equity", + country: "KR", + aliases: ["lg energy solution", "lg엔솔", "lg energy", "lg에너지솔루션", "엘지에너지솔루션"], + }, + { + ticker: "207940.KS", + name: "Samsung Biologics Co., Ltd.", + nameKo: "삼성바이오로직스", + exchange: "KOSPI", + market: "Korea Main Board", + currency: "KRW", + assetType: "Equity", + country: "KR", + aliases: ["samsung biologics", "삼성바이오로직스", "삼바"], + }, + { + ticker: "068270.KS", + name: "Celltrion, Inc.", + nameKo: "셀트리온", + exchange: "KOSPI", + market: "Korea Main Board", + currency: "KRW", + assetType: "Equity", + country: "KR", + aliases: ["celltrion", "셀트리온"], + }, + { + ticker: "005490.KS", + name: "POSCO Holdings Inc.", + nameKo: "포스코홀딩스", + exchange: "KOSPI", + market: "Korea Main Board", + currency: "KRW", + assetType: "Equity", + country: "KR", + aliases: ["posco", "posco holdings", "포스코", "포스코홀딩스"], + }, + { + ticker: "012330.KS", + name: "Hyundai Mobis Co., Ltd.", + nameKo: "현대모비스", + exchange: "KOSPI", + market: "Korea Main Board", + currency: "KRW", + assetType: "Equity", + country: "KR", + aliases: ["hyundai mobis", "mobis", "현대모비스", "모비스"], + }, + { + ticker: "055550.KS", + name: "Shinhan Financial Group Co., Ltd.", + nameKo: "신한지주", + exchange: "KOSPI", + market: "Korea Main Board", + currency: "KRW", + assetType: "Equity", + country: "KR", + aliases: ["shinhan", "shinhan financial", "신한지주", "신한금융지주"], + }, + { + ticker: "105560.KS", + name: "KB Financial Group Inc.", + nameKo: "KB금융", + exchange: "KOSPI", + market: "Korea Main Board", + currency: "KRW", + assetType: "Equity", + country: "KR", + aliases: ["kb financial", "kbfg", "kb금융", "국민은행지주", "kb"], + }, + { + ticker: "066570.KS", + name: "LG Electronics Inc.", + nameKo: "LG전자", + exchange: "KOSPI", + market: "Korea Main Board", + currency: "KRW", + assetType: "Equity", + country: "KR", + aliases: ["lg electronics", "lge", "lg전자", "엘지전자"], + }, + { + ticker: "003670.KS", + name: "POSCO Future M Co., Ltd.", + nameKo: "포스코퓨처엠", + exchange: "KOSPI", + market: "Korea Main Board", + currency: "KRW", + assetType: "Equity", + country: "KR", + aliases: ["posco future m", "포스코퓨처엠", "포스코케미칼"], + }, + { + ticker: "035900.KQ", + name: "JYP Entertainment Corporation", + nameKo: "JYP엔터테인먼트", + exchange: "KOSDAQ", + market: "Korea Growth Board", + currency: "KRW", + assetType: "Equity", + country: "KR", + aliases: ["jyp", "jyp entertainment", "jyp엔터", "jyp엔터테인먼트"], + }, + { + ticker: "041510.KQ", + name: "SM Entertainment Co., Ltd.", + nameKo: "에스엠", + exchange: "KOSDAQ", + market: "Korea Growth Board", + currency: "KRW", + assetType: "Equity", + country: "KR", + aliases: ["sm entertainment", "sment", "에스엠", "sm엔터"], + }, + { + ticker: "091990.KQ", + name: "Celltrion Healthcare Co., Ltd.", + nameKo: "셀트리온헬스케어", + exchange: "KOSDAQ", + market: "Korea Growth Board", + currency: "KRW", + assetType: "Equity", + country: "KR", + aliases: ["celltrion healthcare", "셀트리온헬스케어"], + }, { ticker: "7203.T", name: "Toyota Motor Corporation", @@ -267,7 +428,17 @@ function compactSearchText(value: string): string { } function suggestionTerms(suggestion: TickerSuggestion): string[] { - return [suggestion.ticker, suggestion.name, suggestion.exchange, suggestion.country ?? "", suggestion.assetType, ...suggestion.aliases].filter(Boolean); + return [ + suggestion.ticker, + suggestion.name, + suggestion.nameKo ?? "", + suggestion.exchange, + suggestion.market ?? "", + suggestion.currency ?? "", + suggestion.country ?? "", + suggestion.assetType, + ...suggestion.aliases, + ].filter(Boolean); } function initials(value: string): string { @@ -283,6 +454,7 @@ function scoreSuggestion(suggestion: TickerSuggestion, query: string, compactQue const terms = suggestionTerms(suggestion); let best = 0; + const hasHangulQuery = /[가-힣]/.test(query); for (const term of terms) { const normalized = normalizeSearchText(term); @@ -297,10 +469,18 @@ function scoreSuggestion(suggestion: TickerSuggestion, query: string, compactQue if (compactQuery.length >= 2 && initials(term) === compactQuery) best = Math.max(best, 420); } + if (suggestion.country === "KR") { + if (hasHangulQuery && /[가-힣]/.test(suggestion.nameKo ?? suggestion.aliases.join(" "))) { + best += 60; + } + if (query.includes("kospi") && suggestion.exchange === "KOSPI") best += 80; + if (query.includes("kosdaq") && suggestion.exchange === "KOSDAQ") best += 80; + } + return best; } -export function searchTickerSuggestions(raw: string, limit = 6): TickerSuggestion[] { +export function searchLocalTickerSuggestions(raw: string, limit = 6): TickerSuggestion[] { const query = normalizeSearchText(raw); const compactQuery = compactSearchText(raw); if (!query) return []; @@ -315,6 +495,8 @@ export function searchTickerSuggestions(raw: string, limit = 6): TickerSuggestio .map((item) => item.suggestion); } +export const searchTickerSuggestions = searchLocalTickerSuggestions; + export function resolveTickerFromSearch(raw: string): string | null { const query = normalizeSearchText(raw); const compactQuery = compactSearchText(raw); @@ -334,7 +516,7 @@ export function resolveTickerFromSearch(raw: string): string | null { } if (query.length >= 3) { - const [top] = searchTickerSuggestions(raw, 1); + const [top] = searchLocalTickerSuggestions(raw, 1); if (!top) return null; const terms = suggestionTerms(top); const confidentPrefix = terms.some((term) => normalizeSearchText(term).startsWith(query) || compactSearchText(term).startsWith(compactQuery)); diff --git a/atlas-terminal/apps/web/src/app/lib/use-daily-news.ts b/atlas-terminal/apps/web/src/app/lib/use-daily-news.ts new file mode 100644 index 0000000..24a6e3b --- /dev/null +++ b/atlas-terminal/apps/web/src/app/lib/use-daily-news.ts @@ -0,0 +1,114 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { FTBookmark, FTHeadline } from "./daily-news-types"; + +const READ_URLS_KEY = "ft-read-urls"; +const READ_DATES_KEY = "ft-read-dates"; +const BOOKMARKS_KEY = "ft-bookmarks"; +const BOOKMARK_DATES_KEY = "ft-bookmarked-dates"; +const EVENT_NAME = "atlas-daily-news-sync"; + +function readJson(key: string, fallback: T): T { + if (typeof window === "undefined") return fallback; + try { + const raw = localStorage.getItem(key); + return raw ? (JSON.parse(raw) as T) : fallback; + } catch { + return fallback; + } +} + +function writeJson(key: string, value: unknown) { + localStorage.setItem(key, JSON.stringify(value)); +} + +function emitSync() { + window.dispatchEvent(new CustomEvent(EVENT_NAME)); +} + +export function useReadStatus() { + const [readUrls, setReadUrls] = useState([]); + const [readDates, setReadDates] = useState([]); + + const sync = useCallback(() => { + setReadUrls(readJson(READ_URLS_KEY, [])); + setReadDates(readJson(READ_DATES_KEY, [])); + }, []); + + useEffect(() => { + sync(); + window.addEventListener(EVENT_NAME, sync); + return () => window.removeEventListener(EVENT_NAME, sync); + }, [sync]); + + const toggleRead = useCallback((url: string, dateKey: string) => { + const nextUrls = new Set(readJson(READ_URLS_KEY, [])); + const nextDates = new Set(readJson(READ_DATES_KEY, [])); + if (nextUrls.has(url)) { + nextUrls.delete(url); + } else { + nextUrls.add(url); + nextDates.add(dateKey); + } + writeJson(READ_URLS_KEY, Array.from(nextUrls)); + writeJson(READ_DATES_KEY, Array.from(nextDates)); + emitSync(); + }, []); + + const readUrlSet = useMemo(() => new Set(readUrls), [readUrls]); + const readDateSet = useMemo(() => new Set(readDates), [readDates]); + + return { + readDates: readDateSet, + isRead: (url: string) => readUrlSet.has(url), + toggleRead, + }; +} + +export function useBookmarks() { + const [bookmarks, setBookmarks] = useState([]); + const [bookmarkedDates, setBookmarkedDates] = useState([]); + + const sync = useCallback(() => { + setBookmarks(readJson(BOOKMARKS_KEY, [])); + setBookmarkedDates(readJson(BOOKMARK_DATES_KEY, [])); + }, []); + + useEffect(() => { + sync(); + window.addEventListener(EVENT_NAME, sync); + return () => window.removeEventListener(EVENT_NAME, sync); + }, [sync]); + + const toggleBookmark = useCallback((headline: FTHeadline, dateKey: string) => { + const nextBookmarks = readJson(BOOKMARKS_KEY, []); + const nextDates = new Set(readJson(BOOKMARK_DATES_KEY, [])); + const existingIndex = nextBookmarks.findIndex((item) => item.url === headline.url); + + if (existingIndex >= 0) { + nextBookmarks.splice(existingIndex, 1); + } else { + nextBookmarks.unshift({ + ...headline, + date_key: dateKey, + saved_at: new Date().toISOString(), + }); + nextDates.add(dateKey); + } + + writeJson(BOOKMARKS_KEY, nextBookmarks); + writeJson(BOOKMARK_DATES_KEY, Array.from(nextDates)); + emitSync(); + }, []); + + const bookmarkSet = useMemo(() => new Set(bookmarks.map((item) => item.url)), [bookmarks]); + const bookmarkDateSet = useMemo(() => new Set(bookmarkedDates), [bookmarkedDates]); + + return { + bookmarks, + bookmarkedDates: bookmarkDateSet, + isBookmarked: (url: string) => bookmarkSet.has(url), + toggleBookmark, + }; +} diff --git a/atlas-terminal/apps/web/src/app/lib/use-ticker-search.ts b/atlas-terminal/apps/web/src/app/lib/use-ticker-search.ts new file mode 100644 index 0000000..db887bd --- /dev/null +++ b/atlas-terminal/apps/web/src/app/lib/use-ticker-search.ts @@ -0,0 +1,148 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { searchLocalTickerSuggestions, type TickerSuggestion } from "./ticker-alias"; + +const SEARCH_ENDPOINT = "/api/search/tickers"; +const DEFAULT_LIMIT = 6; +const REMOTE_FAILURE_STATUSES = new Set([404, 405, 410, 501]); +const inFlightSearches = new Map>(); + +let remoteSearchAvailable: boolean | null = null; + +type RemoteTickerSuggestion = Partial & { + symbol?: string; + ticker?: string; + name_ko?: string; + asset_type?: string; +}; + +function normalizeRemoteSuggestion(candidate: RemoteTickerSuggestion): TickerSuggestion | null { + const ticker = (candidate.ticker || candidate.symbol || "").trim().toUpperCase(); + const name = (candidate.name || "").trim(); + const exchange = (candidate.exchange || candidate.market || "").trim(); + if (!ticker || !name || !exchange) return null; + + const assetTypeValue = (candidate.assetType || candidate.asset_type || "Equity").toString().toLowerCase(); + const assetType = + assetTypeValue === "etf" + ? "ETF" + : assetTypeValue === "commodity" + ? "Commodity" + : assetTypeValue === "crypto" + ? "Crypto" + : assetTypeValue === "index" + ? "Index" + : "Equity"; + + return { + ticker, + name, + nameKo: candidate.nameKo || candidate.name_ko, + exchange, + market: candidate.market, + currency: candidate.currency, + assetType, + country: candidate.country, + aliases: Array.isArray(candidate.aliases) ? candidate.aliases.filter((value): value is string => typeof value === "string") : [], + }; +} + +async function fetchRemoteTickerSuggestions(raw: string, limit: number, signal: AbortSignal): Promise { + if (remoteSearchAvailable === false) return []; + + const query = raw.trim(); + if (!query) return []; + + const cacheKey = `${query}::${limit}`; + const existing = inFlightSearches.get(cacheKey); + if (existing) return existing; + + const task = (async () => { + const res = await fetch(`${SEARCH_ENDPOINT}?q=${encodeURIComponent(query)}&limit=${limit}`, { signal }); + if (REMOTE_FAILURE_STATUSES.has(res.status)) { + remoteSearchAvailable = false; + return []; + } + if (!res.ok) { + throw new Error(`HTTP_${res.status}`); + } + + remoteSearchAvailable = true; + const payload = await res.json(); + const items: RemoteTickerSuggestion[] = Array.isArray(payload) + ? payload + : Array.isArray(payload?.suggestions) + ? payload.suggestions + : Array.isArray(payload?.results) + ? payload.results + : []; + + return items + .map((item: RemoteTickerSuggestion) => normalizeRemoteSuggestion(item)) + .filter((item): item is TickerSuggestion => Boolean(item)); + })(); + + inFlightSearches.set(cacheKey, task); + try { + return await task; + } finally { + inFlightSearches.delete(cacheKey); + } +} + +function mergeSuggestions(local: TickerSuggestion[], remote: TickerSuggestion[], limit: number): TickerSuggestion[] { + const merged = new Map(); + for (const suggestion of [...remote, ...local]) { + const key = `${suggestion.ticker}::${suggestion.exchange}`; + if (!merged.has(key)) merged.set(key, suggestion); + } + return Array.from(merged.values()).slice(0, limit); +} + +export function useTickerSearch(raw: string, limit = DEFAULT_LIMIT) { + const query = raw.trim(); + const localSuggestions = useMemo(() => searchLocalTickerSuggestions(query, limit), [query, limit]); + const [remoteSuggestions, setRemoteSuggestions] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setRemoteSuggestions([]); + + if (!query || remoteSearchAvailable === false) { + setLoading(false); + return; + } + + const controller = new AbortController(); + const timeout = window.setTimeout(async () => { + setLoading(true); + try { + const next = await fetchRemoteTickerSuggestions(query, limit, controller.signal); + if (!controller.signal.aborted) { + setRemoteSuggestions(next); + } + } catch (error) { + if (controller.signal.aborted) return; + if (error instanceof Error && error.message.startsWith("HTTP_")) { + setRemoteSuggestions([]); + } + } finally { + if (!controller.signal.aborted) { + setLoading(false); + } + } + }, 140); + + return () => { + controller.abort(); + window.clearTimeout(timeout); + }; + }, [query, limit]); + + return { + suggestions: mergeSuggestions(localSuggestions, remoteSuggestions, limit), + loading, + source: remoteSuggestions.length > 0 ? "remote" : "local", + } as const; +} diff --git a/atlas-terminal/server/main.py b/atlas-terminal/server/main.py index e5aea26..38b39cc 100644 --- a/atlas-terminal/server/main.py +++ b/atlas-terminal/server/main.py @@ -81,7 +81,36 @@ app.add_middleware( ) # --- Mount routers --- -from server.routers import edgar, analysis, valuation, market_data, news, crypto, fx, portfolio, technical, financials, estimates, earnings, insider, screener, markets, fmp, macro, dart, edinet, research, chat, copilot, credentials, calendar, tax, video_transcript # noqa: E402 +from server.routers import ( # noqa: E402 + analysis, + calendar, + chat, + copilot, + credentials, + crypto, + daily_news, + dart, + edgar, + edinet, + earnings, + estimates, + financials, + fmp, + fx, + insider, + macro, + market_data, + markets, + news, + portfolio, + research, + screener, + search, + tax, + technical, + valuation, + video_transcript, +) app.include_router(edgar.router, prefix="/api/edgar", tags=["SEC EDGAR"]) app.include_router(analysis.router, prefix="/api/analysis", tags=["AI Analysis"]) @@ -107,8 +136,10 @@ app.include_router(chat.router, prefix="/api/chat", tags=["Chat"]) app.include_router(copilot.router, prefix="/api/copilot", tags=["Copilot"]) app.include_router(credentials.router, prefix="/api/credentials", tags=["Credentials"]) app.include_router(calendar.router, prefix="/api/calendar", tags=["Calendar"]) +app.include_router(daily_news.router, prefix="/api/daily-news", tags=["Daily News"]) app.include_router(video_transcript.router, prefix="/api/video", tags=["Video Transcript"]) app.include_router(tax.router, prefix="/api/tax", tags=["Tax"]) +app.include_router(search.router, prefix="/api/search", tags=["Search"]) @app.get("/health") diff --git a/atlas-terminal/server/models/schemas.py b/atlas-terminal/server/models/schemas.py index 5f71134..ff69940 100644 --- a/atlas-terminal/server/models/schemas.py +++ b/atlas-terminal/server/models/schemas.py @@ -120,6 +120,18 @@ class ConsensusResponse(BaseModel): data: Dict[str, Any] = {} +class FTHeadline(BaseModel): + """Translated FT headline card for the daily news view.""" + url: str + title_en: str + title_ko: Optional[str] = None + lede_en: Optional[str] = None + lede_ko: Optional[str] = None + section: Optional[str] = None + published_at: str + image: Optional[str] = None + + class SectorIndustryResponse(BaseModel): """Sector and industry classification.""" sector: str = "N/A" @@ -337,6 +349,8 @@ class CryptoPrice(BaseModel): class EdgarSectionsResponse(BaseModel): """Cached or downloaded filing section texts (SEC, DART, or EDINET).""" source: Literal["sec", "dart", "edinet"] = "sec" + filing_form: Optional[str] = None + filing_label: Optional[str] = None configured: bool = True message: Optional[str] = None links: Optional[Dict[str, str]] = None @@ -369,7 +383,6 @@ class HealthCheckResponse(BaseModel): version: str = "1.0.0" - class VideoSubmitRequest(BaseModel): """Request to start transcript extraction for a video or audio source.""" url: str = Field(..., description="YouTube URL, web URL, or local file path") diff --git a/atlas-terminal/server/routers/daily_news.py b/atlas-terminal/server/routers/daily_news.py new file mode 100644 index 0000000..46bf97f --- /dev/null +++ b/atlas-terminal/server/routers/daily_news.py @@ -0,0 +1,47 @@ +"""Daily FT news endpoints.""" + +from __future__ import annotations + +from datetime import date + +from fastapi import APIRouter, Header, HTTPException, Query + +from server.models.schemas import FTHeadline +from server.services.ft_epaper_service import fetch_headlines, translate_headlines + +router = APIRouter() + +EPAPER_LINKS = { + "international": "https://www.ft.com/todaysnewspaper/international", + "uk": "https://www.ft.com/todaysnewspaper/uk", +} + + +@router.get("/today-link", summary="FT ePaper link for opening in a new tab") +async def ft_today_link(edition: str = Query("international", pattern="^(international|uk)$")) -> dict[str, str]: + return {"edition": edition, "url": EPAPER_LINKS[edition]} + + +@router.get("/{target_date}", response_model=list[FTHeadline], summary="FT daily headlines with Korean translation") +async def daily_news_by_date( + target_date: str, + x_gemini_api_key: str | None = Header(default=None), +) -> list[FTHeadline]: + try: + requested = date.fromisoformat(target_date) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Date must be YYYY-MM-DD.") from exc + + today = date.today() + if requested > today: + raise HTTPException(status_code=400, detail="Future dates are not available yet.") + if (today - requested).days > 7: + raise HTTPException( + status_code=400, + detail="FT RSS currently covers the most recent 7 days. Open the FT archive in a new tab for older dates.", + ) + + items = await fetch_headlines(requested) + api_key = (x_gemini_api_key or "").strip() or None + translated = await translate_headlines(items, api_key=api_key) + return [FTHeadline(**item) for item in translated] diff --git a/atlas-terminal/server/routers/market_data.py b/atlas-terminal/server/routers/market_data.py index 8a4c1c0..de54d95 100644 --- a/atlas-terminal/server/routers/market_data.py +++ b/atlas-terminal/server/routers/market_data.py @@ -8,6 +8,11 @@ from typing import Any, Dict, List from fastapi import APIRouter, Query from server.core import flags as core_flags from server.core.factory import get_data_gateway +from server.services.korean_stock_universe import ( + get_korean_stock_record, + korean_stock_universe_metadata, + search_korean_stock_universe, +) from server.utils.ticker_utils import AssetType, detect_asset_type router = APIRouter() @@ -191,6 +196,31 @@ async def asset_type_by_ticker(ticker: str): return {"error": str(e), "ticker": ticker.upper(), "asset_type": AssetType.EQUITY.value} +@router.get("/korean-universe/search", summary="Search local KOSPI/KOSDAQ universe") +async def korean_universe_search( + q: str = Query("", description="Ticker code, Korean name, English name, or alias"), + market: str | None = Query(None, description="Optional market filter: KOSPI or KOSDAQ"), + limit: int = Query(10, ge=1, le=50), +): + results = [record.to_dict() for record in search_korean_stock_universe(q, market=market, limit=limit)] + metadata = korean_stock_universe_metadata() + return { + "query": q, + "market": (market or "").upper() or None, + "count": len(results), + "items": results, + "source_path": metadata["source_path"], + } + + +@router.get("/korean-universe/{ticker}", summary="Lookup a Korean stock from local universe") +async def korean_universe_lookup(ticker: str): + record = get_korean_stock_record(ticker) + if not record: + return {"ticker": ticker.upper(), "item": None} + return {"ticker": ticker.upper(), "item": record.to_dict()} + + @router.get("/etf/{ticker}/holdings", summary="ETF top holdings") async def etf_holdings(ticker: str): try: @@ -228,42 +258,44 @@ async def commodity_correlations(ticker: str): @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" + from server.services.etf_analysis import get_equity_overview + + overview = await get_equity_overview(ticker) return { - "sector": info.get("sector", "N/A"), - "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"), - "hq": hq, - "website": info.get("website"), - "ipo_date": info.get("ipoExpectedDate") or info.get("firstTradeDateEpochUtc"), - "description": info.get("longBusinessSummary"), - "currency": info.get("currency") or info.get("financialCurrency") or "USD", - "exchange": info.get("exchange"), + "name": overview.get("name"), + "name_ko": overview.get("name_ko"), + "stock_code": overview.get("stock_code"), + "sector": overview.get("sector") or "N/A", + "industry": overview.get("industry") or "N/A", + "market_cap": _safe_float(overview.get("market_cap")), + "pe_ratio": _safe_float(overview.get("pe_ratio")) or _safe_float(overview.get("forward_pe")), + "forward_pe": _safe_float(overview.get("forward_pe")), + "dividend_yield": _safe_float(overview.get("dividend_yield")), + "beta": _safe_float(overview.get("beta")), + "fifty_two_week_high": _safe_float(overview.get("high_52w")), + "fifty_two_week_low": _safe_float(overview.get("low_52w")), + "current_price": _safe_float(overview.get("price")), + "target_mean_price": _safe_float(overview.get("target_mean_price")), + "target_high_price": _safe_float(overview.get("target_high_price")), + "target_low_price": _safe_float(overview.get("target_low_price")), + "recommendation": overview.get("recommendation"), + "analyst_count": overview.get("num_analysts"), + "forward_eps": _safe_float(overview.get("forward_eps")), + "trailing_eps": _safe_float(overview.get("trailing_eps")), + "peg_ratio": _safe_float(overview.get("peg_ratio")), + "ceo": overview.get("ceo"), + "employees": overview.get("full_time_employees"), + "founded": overview.get("founded"), + "hq": overview.get("hq") or "N/A", + "website": overview.get("website"), + "ipo_date": overview.get("ipo_date"), + "description": overview.get("description"), + "currency": overview.get("currency") or "USD", + "exchange": overview.get("exchange"), + "market": overview.get("market"), + "source": overview.get("source"), + "change": _safe_float(overview.get("change")), + "change_pct": _safe_float(overview.get("change_pct")), } except Exception: logger.exception("sector/%s failed", ticker) diff --git a/atlas-terminal/server/routers/search.py b/atlas-terminal/server/routers/search.py new file mode 100644 index 0000000..2f069cb --- /dev/null +++ b/atlas-terminal/server/routers/search.py @@ -0,0 +1,20 @@ +"""Search router for ticker autocomplete.""" + +from fastapi import APIRouter, Query + +from server.services.korean_market import search_korean_tickers + +router = APIRouter() + + +@router.get("/tickers", summary="Ticker autocomplete with full KOSPI/KOSDAQ coverage") +async def ticker_search( + q: str = Query(..., min_length=1, description="Ticker, company name, or Korean company name"), + limit: int = Query(8, ge=1, le=20), +): + suggestions = await search_korean_tickers(q, limit=limit) + return { + "query": q, + "count": len(suggestions), + "suggestions": suggestions, + } diff --git a/atlas-terminal/server/services/etf_analysis.py b/atlas-terminal/server/services/etf_analysis.py index acc54a7..c9fefbd 100644 --- a/atlas-terminal/server/services/etf_analysis.py +++ b/atlas-terminal/server/services/etf_analysis.py @@ -145,7 +145,10 @@ async def get_etf_overview(ticker: str) -> dict: async def get_equity_overview(ticker: str) -> dict: import yfinance as yf - t = yf.Ticker(ticker.upper()) + from server.services.korean_market import get_naver_stock_snapshot, is_korean_equity_ticker + + normalized = ticker.upper() + t = yf.Ticker(normalized) info = t.info or {} # Fallback: derive 52W low/high from history when yfinance returns 0 or None @@ -165,25 +168,53 @@ async def get_equity_overview(ticker: str) -> dict: except Exception: pass + city = (info.get("city") or "").strip() + state = (info.get("state") or "").strip() + country = (info.get("country") or "").strip() + hq_parts = [part for part in [city, state, country] if part] + hq = ", ".join(hq_parts) if hq_parts else None + + naver = await get_naver_stock_snapshot(normalized) if is_korean_equity_ticker(normalized) else None + + price = _safe_num(info.get("currentPrice") or info.get("regularMarketPrice")) + market_cap = _safe_num(info.get("marketCap")) + currency = info.get("currency") or info.get("financialCurrency") or "USD" + exchange = info.get("exchange") + country_name = info.get("country") + description = info.get("longBusinessSummary") + + if naver: + price = _safe_num(naver.get("current_price")) or price + market_cap = _safe_num(naver.get("market_cap")) or market_cap + hi52 = _safe_num(naver.get("fifty_two_week_high")) or hi52 + lo52 = _safe_num(naver.get("fifty_two_week_low")) or lo52 + currency = str(naver.get("currency") or currency or "KRW") + exchange = str(naver.get("exchange") or exchange or "") + country_name = str(naver.get("country") or country_name or "KR") + description = str(naver.get("description") or description or "") + return { - "name": info.get("longName") or info.get("shortName", ticker.upper()), + "name": naver.get("name") if naver else info.get("longName") or info.get("shortName", normalized), + "name_ko": naver.get("name_ko") if naver else None, "sector": info.get("sector"), "industry": info.get("industry"), - "market_cap": _safe_num(info.get("marketCap")), + "market_cap": market_cap, "pe_ratio": _safe_num(info.get("trailingPE")) or _safe_num(info.get("forwardPE")), - "dividend_yield": _safe_num(info.get("dividendYield")), + "dividend_yield": _safe_num(info.get("dividendYield")) or _safe_num(naver.get("dividend_yield") / 100.0 if naver and naver.get("dividend_yield") is not None else None), "beta": _safe_num(info.get("beta")), "high_52w": hi52, "low_52w": lo52, - "price": _safe_num(info.get("currentPrice") or info.get("regularMarketPrice")), - "description": info.get("longBusinessSummary"), - "currency": info.get("currency") or info.get("financialCurrency") or "USD", - "exchange": info.get("exchange"), - "country": info.get("country"), + "price": price, + "description": description, + "currency": currency, + "exchange": exchange, + "country": country_name, + "market": naver.get("market") if naver else None, + "stock_code": naver.get("stock_code") if naver else None, "full_time_employees": info.get("fullTimeEmployees"), "average_volume": _safe_num(info.get("averageVolume")), "trailing_pe": _safe_num(info.get("trailingPE")), - "forward_pe": _safe_num(info.get("forwardPE")), + "forward_pe": _safe_num(naver.get("forward_pe") if naver else None) or _safe_num(info.get("forwardPE")), "enterprise_to_ebitda": _safe_num(info.get("enterpriseToEbitda")), "debt_to_equity": _safe_num(info.get("debtToEquity")), "return_on_equity": _safe_num(info.get("returnOnEquity")), @@ -191,7 +222,20 @@ async def get_equity_overview(ticker: str) -> dict: "free_cashflow": _safe_num(info.get("freeCashflow")), "revenue_growth": _safe_num(info.get("revenueGrowth")), "profit_margins": _safe_num(info.get("profitMargins")), - "target_mean_price": _safe_num(info.get("targetMeanPrice")), - "recommendation": info.get("recommendationKey"), + "target_mean_price": _safe_num(naver.get("target_mean_price") if naver else None) or _safe_num(info.get("targetMeanPrice")), + "target_high_price": _safe_num(info.get("targetHighPrice")), + "target_low_price": _safe_num(info.get("targetLowPrice")), + "recommendation": naver.get("recommendation") if naver else info.get("recommendationKey"), "num_analysts": info.get("numberOfAnalystOpinions"), + "change": _safe_num(naver.get("change") if naver else None), + "change_pct": _safe_num(naver.get("change_pct") if naver else None), + "forward_eps": _safe_num(naver.get("forward_eps") if naver else None) or _safe_num(info.get("forwardEps")), + "trailing_eps": _safe_num(naver.get("trailing_eps") if naver else None) or _safe_num(info.get("trailingEps")), + "peg_ratio": _safe_num(info.get("pegRatio")), + "ceo": info.get("companyOfficers", [{}])[0].get("name") if isinstance(info.get("companyOfficers"), list) and info.get("companyOfficers") else None, + "founded": info.get("founded"), + "hq": hq, + "website": info.get("website"), + "ipo_date": info.get("ipoExpectedDate") or info.get("firstTradeDateEpochUtc"), + "source": naver.get("source") if naver else "yfinance", } diff --git a/atlas-terminal/server/services/ft_epaper_service.py b/atlas-terminal/server/services/ft_epaper_service.py new file mode 100644 index 0000000..7c4fb2f --- /dev/null +++ b/atlas-terminal/server/services/ft_epaper_service.py @@ -0,0 +1,261 @@ +"""FT daily news ingestion via public RSS feeds plus cached Korean translation.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import logging +import re +from datetime import date, datetime, timezone +from email.utils import parsedate_to_datetime +from typing import Any + +import feedparser +import httpx +from bs4 import BeautifulSoup + +from server.db.unified_repo import repo +from server.services.gemini_service import generate_text + +logger = logging.getLogger(__name__) + +RSS_FEEDS: list[tuple[str, str]] = [ + ("Home", "https://www.ft.com/rss/home/uk"), + ("World", "https://www.ft.com/rss/world"), + ("Companies", "https://www.ft.com/companies?format=rss"), + ("Markets", "https://www.ft.com/markets?format=rss"), +] +USER_AGENT = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" +) +HEADERS = { + "User-Agent": USER_AGENT, + "Accept-Language": "en-GB,en;q=0.9", +} +HEADLINE_TTL_SECONDS = 86_400 +TRANSLATION_TTL_SECONDS = 31_536_000 +BATCH_SIZE = 6 + + +def _hash_url(url: str) -> str: + return hashlib.sha1(url.encode("utf-8")).hexdigest()[:20] + + +def _date_cache_key(target_date: date, limit: int) -> str: + return f"ft:daily:{target_date.isoformat()}:{limit}" + + +def _meta_cache_key(url: str) -> str: + return f"ft:meta:{_hash_url(url)}" + + +def _translation_cache_key(url: str) -> str: + return f"ft_trans:{_hash_url(url)}" + + +def _clean_json_payload(raw: str) -> str: + text = (raw or "").strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text) + text = re.sub(r"\s*```$", "", text) + match = re.search(r"(\[\s*{.*}\s*\])", text, flags=re.S) + if match: + return match.group(1) + return text + + +def _entry_datetime(entry: Any) -> datetime | None: + parsed = getattr(entry, "published_parsed", None) or getattr(entry, "updated_parsed", None) + if parsed: + try: + return datetime(*parsed[:6], tzinfo=timezone.utc) + except Exception: + return None + published = getattr(entry, "published", None) or getattr(entry, "updated", None) + if published: + try: + dt = parsedate_to_datetime(published) + return dt.astimezone(timezone.utc) if dt.tzinfo else dt.replace(tzinfo=timezone.utc) + except Exception: + return None + return None + + +def _entry_section(entry: Any, fallback: str) -> str: + tags = getattr(entry, "tags", None) or [] + if tags: + for tag in tags: + term = getattr(tag, "term", None) or (tag.get("term") if isinstance(tag, dict) else None) + if term: + return str(term) + category = getattr(entry, "category", None) + return str(category or fallback) + + +async def _fetch_feed_entries(client: httpx.AsyncClient, section: str, url: str) -> list[dict[str, Any]]: + response = await client.get(url) + response.raise_for_status() + parsed = feedparser.parse(response.text) + rows: list[dict[str, Any]] = [] + for entry in parsed.entries: + published_at = _entry_datetime(entry) + if published_at is None: + continue + link = getattr(entry, "link", None) + title = getattr(entry, "title", None) + if not link or not title: + continue + rows.append( + { + "url": str(link), + "title_en": str(title).strip(), + "lede_en": getattr(entry, "summary", None), + "section": _entry_section(entry, section), + "published_at": published_at.isoformat(), + } + ) + return rows + + +async def _fetch_article_meta(client: httpx.AsyncClient, url: str) -> dict[str, Any]: + cached = await repo.cache_get(_meta_cache_key(url)) + if isinstance(cached, dict): + return cached + + try: + response = await client.get(url) + response.raise_for_status() + soup = BeautifulSoup(response.text, "lxml") + description = None + image = None + for prop in ("og:description", "twitter:description", "description"): + node = soup.find("meta", attrs={"property": prop}) or soup.find("meta", attrs={"name": prop}) + if node and node.get("content"): + description = str(node["content"]).strip() + if description: + break + for prop in ("og:image", "twitter:image"): + node = soup.find("meta", attrs={"property": prop}) or soup.find("meta", attrs={"name": prop}) + if node and node.get("content"): + image = str(node["content"]).strip() + if image: + break + payload = {"lede_en": description, "image": image} + except Exception as exc: + logger.warning("ft_epaper: article meta fetch failed for %s: %s", url, exc) + payload = {"lede_en": None, "image": None} + + await repo.cache_set(_meta_cache_key(url), payload, ttl=HEADLINE_TTL_SECONDS) + return payload + + +async def fetch_headlines(target_date: date, limit: int = 30) -> list[dict[str, Any]]: + """Fetch FT RSS headlines for a given date, enriched with public og metadata.""" + + cache_key = _date_cache_key(target_date, limit) + cached = await repo.cache_get(cache_key) + if isinstance(cached, list): + return cached + + async with httpx.AsyncClient(headers=HEADERS, timeout=15.0, follow_redirects=True) as client: + feed_rows = await asyncio.gather( + *[_fetch_feed_entries(client, section, url) for section, url in RSS_FEEDS], + return_exceptions=True, + ) + + entries: list[dict[str, Any]] = [] + for result in feed_rows: + if isinstance(result, Exception): + logger.warning("ft_epaper: RSS fetch failed: %s", result) + continue + entries.extend(result) + + seen: set[str] = set() + filtered: list[dict[str, Any]] = [] + for item in sorted(entries, key=lambda row: row["published_at"], reverse=True): + published_date = datetime.fromisoformat(item["published_at"]).date() + if published_date != target_date: + continue + if item["url"] in seen: + continue + seen.add(item["url"]) + filtered.append(item) + if len(filtered) >= limit: + break + + semaphore = asyncio.Semaphore(4) + + async def enrich(item: dict[str, Any]) -> dict[str, Any]: + async with semaphore: + meta = await _fetch_article_meta(client, item["url"]) + return { + **item, + "lede_en": item.get("lede_en") or meta.get("lede_en"), + "image": meta.get("image"), + "title_ko": None, + "lede_ko": None, + } + + payload = await asyncio.gather(*[enrich(item) for item in filtered]) + + await repo.cache_set(cache_key, payload, ttl=HEADLINE_TTL_SECONDS) + return payload + + +async def translate_headlines(items: list[dict[str, Any]], api_key: str | None = None) -> list[dict[str, Any]]: + """Batch-translate uncached FT items into Korean using Gemini.""" + + if not items: + return [] + + hydrated: list[dict[str, Any]] = [] + pending: list[dict[str, Any]] = [] + for item in items: + cached = await repo.cache_get(_translation_cache_key(item["url"])) + if isinstance(cached, dict): + hydrated.append({**item, "title_ko": cached.get("title_ko"), "lede_ko": cached.get("lede_ko")}) + else: + hydrated.append(dict(item)) + pending.append(item) + + if not pending: + return hydrated + + try: + translation_map: dict[str, dict[str, Any]] = {} + for start in range(0, len(pending), BATCH_SIZE): + batch = pending[start : start + BATCH_SIZE] + prompt = ( + "Translate the following Financial Times headlines and ledes into Korean. " + "Use standard financial terminology (for example: Fed -> 연준, yield -> 수익률, " + "equities -> 주식, bonds -> 채권). Preserve proper nouns and company names. " + "Return ONLY valid JSON array with objects {\"url\": string, \"title_ko\": string, \"lede_ko\": string|null}.\n\n" + f"{json.dumps([{'url': row['url'], 'title_en': row['title_en'], 'lede_en': row.get('lede_en')} for row in batch], ensure_ascii=False)}" + ) + raw = await generate_text(prompt, temperature=0.2, max_tokens=1400, api_key=api_key) + parsed = json.loads(_clean_json_payload(raw)) + if not isinstance(parsed, list): + continue + for row in parsed: + if not isinstance(row, dict) or not row.get("url"): + continue + translation_map[str(row["url"])] = { + "title_ko": row.get("title_ko"), + "lede_ko": row.get("lede_ko"), + } + except Exception as exc: + logger.warning("ft_epaper: Gemini translation failed, returning EN-only payload: %s", exc) + return hydrated + + final_items: list[dict[str, Any]] = [] + for item in hydrated: + translated = translation_map.get(item["url"]) + if translated: + merged = {**item, **translated} + await repo.cache_set(_translation_cache_key(item["url"]), translated, ttl=TRANSLATION_TTL_SECONDS) + final_items.append(merged) + else: + final_items.append(item) + return final_items diff --git a/atlas-terminal/server/services/korean_market.py b/atlas-terminal/server/services/korean_market.py new file mode 100644 index 0000000..4fcb2a8 --- /dev/null +++ b/atlas-terminal/server/services/korean_market.py @@ -0,0 +1,623 @@ +"""Korean market helpers for KOSPI/KOSDAQ universe search and Naver snapshots.""" + +from __future__ import annotations + +import asyncio +import logging +import os +import re +import time +import unicodedata +from typing import Any + +import httpx +from bs4 import BeautifulSoup + +from server.utils.ticker_utils import korean_stock_code_from_ticker + +logger = logging.getLogger(__name__) + +_USER_AGENT = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" +) +_COMMON_HEADERS = { + "User-Agent": _USER_AGENT, + "Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7", + "Referer": "https://finance.naver.com/", +} +_KIND_CORP_LIST_URL = "https://kind.krx.co.kr/corpgeneral/corpList.do" +_NAVER_MAIN_URL = "https://finance.naver.com/item/main.naver" +_UNIVERSE_TTL_SECONDS = 60 * 60 * 12 +_NAVER_SNAPSHOT_TTL_SECONDS = 20 + +_UNIVERSE_LOCK = asyncio.Lock() +_UNIVERSE_CACHE: list[dict[str, Any]] | None = None +_UNIVERSE_BY_CODE: dict[str, dict[str, Any]] = {} +_UNIVERSE_TS = 0.0 + +_DART_LOOKUP_LOCK = asyncio.Lock() +_DART_LOOKUP: dict[str, dict[str, Any]] | None = None + +_SNAPSHOT_LOCK = asyncio.Lock() +_SNAPSHOT_CACHE: dict[str, tuple[float, dict[str, Any]]] = {} + +_MANUAL_ALIASES: dict[str, list[str]] = { + "000660": ["sk hynix", "skhynix", "하이닉스", "sk 하이닉스", "sk하이닉스"], + "005930": ["samsung electronics", "삼성전자", "삼성 전자", "삼전"], + "035420": ["naver", "네이버"], + "035720": ["kakao", "카카오"], + "373220": ["lg energy solution", "lg엔솔", "lg에너지솔루션"], + "207940": ["samsung biologics", "삼성바이오로직스", "삼바"], +} + +_STATIC_FALLBACK_UNIVERSE: list[dict[str, Any]] = [ + { + "ticker": "005930.KS", + "stock_code": "005930", + "name": "Samsung Electronics Co., Ltd.", + "name_ko": "삼성전자", + "exchange": "KOSPI", + "market": "Korea Main Board", + "currency": "KRW", + "country": "KR", + "asset_type": "Equity", + "aliases": _MANUAL_ALIASES["005930"], + }, + { + "ticker": "000660.KS", + "stock_code": "000660", + "name": "SK hynix Inc.", + "name_ko": "SK하이닉스", + "exchange": "KOSPI", + "market": "Korea Main Board", + "currency": "KRW", + "country": "KR", + "asset_type": "Equity", + "aliases": _MANUAL_ALIASES["000660"], + }, + { + "ticker": "035420.KS", + "stock_code": "035420", + "name": "NAVER Corporation", + "name_ko": "네이버", + "exchange": "KOSPI", + "market": "Korea Main Board", + "currency": "KRW", + "country": "KR", + "asset_type": "Equity", + "aliases": _MANUAL_ALIASES["035420"], + }, + { + "ticker": "035720.KS", + "stock_code": "035720", + "name": "Kakao Corp.", + "name_ko": "카카오", + "exchange": "KOSPI", + "market": "Korea Main Board", + "currency": "KRW", + "country": "KR", + "asset_type": "Equity", + "aliases": _MANUAL_ALIASES["035720"], + }, +] + + +def is_korean_equity_ticker(ticker: str) -> bool: + normalized = (ticker or "").strip().upper() + return bool(normalized.endswith(".KS") or normalized.endswith(".KQ") or re.fullmatch(r"\d{6}", normalized)) + + +def _stock_code_from_any(value: str) -> str | None: + raw = (value or "").strip().upper() + if re.fullmatch(r"\d{6}", raw): + return raw + return korean_stock_code_from_ticker(raw) + + +def _normalize_search_text(value: str) -> str: + return ( + unicodedata.normalize("NFKC", value.strip()) + .lower() + .replace("&", " and ") + .replace("-", " ") + .replace(".", " ") + .replace("/", " ") + .replace("_", " ") + .replace("(", " ") + .replace(")", " ") + ) + + +def _compact_search_text(value: str) -> str: + return re.sub(r"\s+", "", _normalize_search_text(value)) + + +def _initials(value: str) -> str: + return "".join(part[:1] for part in _normalize_search_text(value).split() if part) + + +def _dedupe_strings(values: list[str]) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + for value in values: + cleaned = value.strip() + if not cleaned: + continue + key = cleaned.casefold() + if key in seen: + continue + seen.add(key) + out.append(cleaned) + return out + + +def _selector_text(soup: BeautifulSoup, selectors: list[str]) -> str | None: + for selector in selectors: + node = soup.select_one(selector) + if node: + text = node.get_text(" ", strip=True) + if text: + return text + return None + + +def _parse_number(text: str | None) -> float | None: + if not text: + return None + cleaned = re.sub(r"[^0-9.\-]", "", text) + if not cleaned: + return None + try: + return float(cleaned) + except ValueError: + return None + + +def _parse_int(text: str | None) -> int | None: + value = _parse_number(text) + if value is None: + return None + return int(value) + + +def _parse_market_cap_krw(text: str | None) -> float | None: + if not text: + return None + compact = text.replace(" ", "").replace(",", "") + total = 0 + + match_jo = re.search(r"(\d+)조", compact) + if match_jo: + total += int(match_jo.group(1)) * 1_000_000_000_000 + + match_eok = re.search(r"(\d+)억", compact) + if match_eok: + total += int(match_eok.group(1)) * 100_000_000 + + if total > 0: + return float(total) + + match_plain_eok = re.search(r"([\d,]+)억원", text) + if match_plain_eok: + return float(int(match_plain_eok.group(1).replace(",", "")) * 100_000_000) + + return None + + +def _normalize_recommendation(label: str | None) -> str | None: + if not label: + return None + raw = label.strip().lower() + if any(token in raw for token in ("매수", "buy", "outperform", "strong buy")): + return "buy" + if any(token in raw for token in ("중립", "hold", "neutral")): + return "hold" + if any(token in raw for token in ("매도", "sell", "underperform")): + return "sell" + return label.strip() + + +def _decode_html(content: bytes, encoding_hint: str | None = None) -> str: + for encoding in [encoding_hint, "euc-kr", "utf-8"]: + if not encoding: + continue + try: + return content.decode(encoding, errors="replace") + except LookupError: + continue + return content.decode("utf-8", errors="replace") + + +def _build_dart_lookup_sync() -> dict[str, dict[str, Any]]: + key = (os.getenv("DART_API_KEY") or "").strip() + if not key: + return {} + + try: + import dart_fss as dart # noqa: WPS433 + except Exception as exc: # pragma: no cover - optional dependency + logger.warning("korean_market: dart_fss import failed: %s", exc) + return {} + + try: + dart.set_api_key(key) + corp_list = dart.get_corp_list() + except Exception as exc: + logger.warning("korean_market: DART corp list load failed: %s", exc) + return {} + + iterable = [] + try: + iterable = list(corp_list) + except Exception: + iterable = list(getattr(corp_list, "corps", []) or []) + + lookup: dict[str, dict[str, Any]] = {} + for item in iterable: + stock_code = str(getattr(item, "stock_code", "") or "").strip() + if not re.fullmatch(r"\d{6}", stock_code): + continue + lookup[stock_code] = { + "corp_code": getattr(item, "corp_code", None), + "corp_name": getattr(item, "corp_name", None), + "corp_eng_name": getattr(item, "corp_eng_name", None), + } + return lookup + + +async def _get_dart_lookup() -> dict[str, dict[str, Any]]: + global _DART_LOOKUP + if _DART_LOOKUP is not None: + return _DART_LOOKUP + + async with _DART_LOOKUP_LOCK: + if _DART_LOOKUP is not None: + return _DART_LOOKUP + _DART_LOOKUP = await asyncio.to_thread(_build_dart_lookup_sync) + return _DART_LOOKUP + + +async def _fetch_kind_market(market_type: str, exchange: str, market_label: str) -> list[dict[str, Any]]: + params = {"method": "download", "marketType": market_type} + async with httpx.AsyncClient(headers=_COMMON_HEADERS, timeout=20.0, follow_redirects=True) as client: + response = await client.get(_KIND_CORP_LIST_URL, params=params) + response.raise_for_status() + html = _decode_html(response.content, response.encoding) + soup = BeautifulSoup(html, "lxml") + table = soup.find("table") + if table is None: + raise ValueError(f"KIND table missing for {exchange}") + + header_cells = table.select("tr th") + headers = [cell.get_text(" ", strip=True) for cell in header_cells] + if not headers: + raise ValueError(f"KIND headers missing for {exchange}") + + body_rows = table.select("tr") + out: list[dict[str, Any]] = [] + for tr in body_rows[1:]: + cells = [cell.get_text(" ", strip=True) for cell in tr.find_all("td")] + if not cells or len(cells) != len(headers): + continue + row = dict(zip(headers, cells)) + name_ko = row.get("회사명") or row.get("기업명") or cells[0] + stock_code = re.sub(r"\D", "", row.get("종목코드") or row.get("종목코드(단축코드)") or "") + if not re.fullmatch(r"\d{6}", stock_code): + continue + ticker = f"{stock_code}.KS" if exchange == "KOSPI" else f"{stock_code}.KQ" + out.append( + { + "ticker": ticker, + "stock_code": stock_code, + "name": name_ko, + "name_ko": name_ko, + "exchange": exchange, + "market": market_label, + "currency": "KRW", + "country": "KR", + "asset_type": "Equity", + "aliases": [name_ko, stock_code, ticker, exchange, market_label], + } + ) + return out + + +def _merge_listing(base: dict[str, Any], dart_lookup: dict[str, dict[str, Any]]) -> dict[str, Any]: + stock_code = base["stock_code"] + dart = dart_lookup.get(stock_code, {}) + name_ko = str(base.get("name_ko") or "").strip() + name_en = str(dart.get("corp_eng_name") or "").strip() + corp_name = str(dart.get("corp_name") or "").strip() + display_name = name_en or corp_name or name_ko or base["ticker"] + aliases = _dedupe_strings( + [ + *list(base.get("aliases", [])), + name_ko, + corp_name, + name_en, + display_name, + *(_MANUAL_ALIASES.get(stock_code) or []), + ] + ) + return { + **base, + "name": display_name, + "name_ko": name_ko or corp_name or display_name, + "name_en": name_en or None, + "corp_code": dart.get("corp_code"), + "aliases": aliases, + } + + +async def get_korean_stock_universe(force_refresh: bool = False) -> list[dict[str, Any]]: + global _UNIVERSE_CACHE, _UNIVERSE_BY_CODE, _UNIVERSE_TS + + now = time.monotonic() + async with _UNIVERSE_LOCK: + if not force_refresh and _UNIVERSE_CACHE and now - _UNIVERSE_TS < _UNIVERSE_TTL_SECONDS: + return _UNIVERSE_CACHE + stale_cache = list(_UNIVERSE_CACHE or []) + + try: + kospi, kosdaq, dart_lookup = await asyncio.gather( + _fetch_kind_market("stockMkt", "KOSPI", "Korea Main Board"), + _fetch_kind_market("kosdaqMkt", "KOSDAQ", "Korea Growth Board"), + _get_dart_lookup(), + ) + listings = [_merge_listing(item, dart_lookup) for item in [*kospi, *kosdaq]] + listings.sort(key=lambda row: row["ticker"]) + by_code = {row["stock_code"]: row for row in listings} + except Exception as exc: + logger.warning("korean_market: universe refresh failed: %s", exc) + if stale_cache: + return stale_cache + listings = list(_STATIC_FALLBACK_UNIVERSE) + by_code = {row["stock_code"]: row for row in listings} + + async with _UNIVERSE_LOCK: + _UNIVERSE_CACHE = listings + _UNIVERSE_BY_CODE = by_code + _UNIVERSE_TS = time.monotonic() + return _UNIVERSE_CACHE + + +async def get_korean_listing(ticker_or_code: str) -> dict[str, Any] | None: + code = _stock_code_from_any(ticker_or_code) + if not code: + return None + universe = await get_korean_stock_universe() + if code in _UNIVERSE_BY_CODE: + return _UNIVERSE_BY_CODE[code] + for listing in universe: + if listing.get("stock_code") == code: + return listing + return None + + +def _listing_terms(listing: dict[str, Any]) -> list[str]: + aliases = listing.get("aliases") + return _dedupe_strings( + [ + str(listing.get("ticker") or ""), + str(listing.get("stock_code") or ""), + str(listing.get("name") or ""), + str(listing.get("name_ko") or ""), + str(listing.get("name_en") or ""), + str(listing.get("exchange") or ""), + str(listing.get("market") or ""), + *(aliases if isinstance(aliases, list) else []), + ] + ) + + +def _score_listing(listing: dict[str, Any], query: str, compact_query: str) -> int: + if not query: + return 0 + + terms = _listing_terms(listing) + best = 0 + has_hangul_query = bool(re.search(r"[가-힣]", query)) + + for term in terms: + normalized = _normalize_search_text(term) + compact = _compact_search_text(term) + upper_term = term.strip().upper() + + if upper_term == query.upper(): + best = max(best, 1400 if upper_term.endswith((".KS", ".KQ")) else 1350) + if normalized == query: + best = max(best, 1200) + if compact == compact_query: + best = max(best, 1150) + if normalized.startswith(query): + best = max(best, 900) + if compact.startswith(compact_query): + best = max(best, 850) + if normalized.find(query) >= 0: + best = max(best, 560) + if compact_query and compact.find(compact_query) >= 0: + best = max(best, 520) + if compact_query and len(compact_query) >= 2 and _initials(term) == compact_query: + best = max(best, 480) + + if has_hangul_query and re.search(r"[가-힣]", str(listing.get("name_ko") or "")): + best += 60 + if "kospi" in query and listing.get("exchange") == "KOSPI": + best += 80 + if "kosdaq" in query and listing.get("exchange") == "KOSDAQ": + best += 80 + + return best + + +async def search_korean_tickers(query: str, limit: int = 8) -> list[dict[str, Any]]: + normalized_query = _normalize_search_text(query) + compact_query = _compact_search_text(query) + if not normalized_query: + return [] + + universe = await get_korean_stock_universe() + ranked = [ + (listing, _score_listing(listing, normalized_query, compact_query)) + for listing in universe + ] + ranked = [item for item in ranked if item[1] > 0] + ranked.sort(key=lambda item: (-item[1], item[0]["ticker"])) + results: list[dict[str, Any]] = [] + for listing, _score in ranked[:limit]: + results.append( + { + "ticker": listing["ticker"], + "symbol": listing["ticker"], + "name": listing["name"], + "name_ko": listing.get("name_ko"), + "exchange": listing["exchange"], + "market": listing.get("market"), + "currency": listing.get("currency", "KRW"), + "country": listing.get("country", "KR"), + "asset_type": listing.get("asset_type", "Equity"), + "aliases": listing.get("aliases", []), + "stock_code": listing.get("stock_code"), + } + ) + return results + + +def _parse_naver_snapshot(html: str, stock_code: str) -> dict[str, Any]: + soup = BeautifulSoup(html, "lxml") + text = soup.get_text("\n", strip=True) + + title = soup.title.get_text(" ", strip=True) if soup.title else stock_code + name = title.split(":")[0].strip() + + price_text = _selector_text( + soup, + [ + "#chart_area #_nowVal", + "#middle .today .no_today .blind", + ".today .no_today .blind", + "p.no_today span.blind", + ], + ) + change_text = _selector_text( + soup, + [ + "#chart_area #_diff", + "#middle .today .no_exday em span.blind", + ".today .no_exday em span.blind", + ], + ) + rate_text = _selector_text( + soup, + [ + "#chart_area #_rate", + "#middle .today .no_exday .blind:last-child", + ".today .no_exday .blind:last-child", + ], + ) + market_sum_text = _selector_text(soup, ["#_market_sum"]) + + current_price = _parse_number(price_text) + if current_price is None: + current_match = re.search(r"현재가\s+([\d,]+)", text) + current_price = _parse_number(current_match.group(1) if current_match else None) + + direction_match = re.search(r"전일대비\s+(상승|하락|보합)", text) + direction = direction_match.group(1) if direction_match else None + change = _parse_number(change_text) + if change is None: + change_match = re.search(r"전일대비\s+(?:상승|하락|보합)\s+([\d,]+)", text) + change = _parse_number(change_match.group(1) if change_match else None) + + change_pct = _parse_number(rate_text) + if change_pct is None: + rate_match = re.search(r"(?:플러스|마이너스)?\s*([\d.]+)\s*퍼센트", text) + change_pct = _parse_number(rate_match.group(1) if rate_match else None) + if direction == "하락" and change_pct is not None: + change_pct *= -1 + if change is not None: + change *= -1 + + market_cap = _parse_market_cap_krw(market_sum_text) + if market_cap is None: + market_cap_match = re.search(r"시가총액(?:\s+시가총액)?\s+([0-9,\s조억]+)", text) + market_cap = _parse_market_cap_krw(market_cap_match.group(1) if market_cap_match else None) + + high_low_match = re.search(r"52주최고\s*[l|I]\s*최저\s+([\d,]+)\s*[l|I]\s*([\d,]+)", text) + trailing_match = re.search(r"PER/EPS\s+([\d.]+)\s+배\s*[l|I]\s*([\d,]+)\s+원", text) + forward_match = re.search(r"추정PER\s*[l|I]\s*EPS\s+([\d.]+)\s+배\s*[l|I]\s*([\d,]+)\s+원", text) + target_match = re.search(r"투자의견\s+투자의견\s*[l|I]\s*목표주가\s+([\d.]+)\s+([가-힣A-Za-z]+)\s*[l|I]\s*([\d,]+)", text) + dividend_match = re.search(r"배당수익률\s+([\d.]+)%", text) + shares_match = re.search(r"상장주식수\s+([\d,]+)", text) + market_match = re.search(r"종목코드\s+\d{6}\s+(코스피|코스닥)", text) + summary_match = re.search(r"기업개요\s+(.*?)\s+출처\s*:\s*에프앤가이드", text, re.S) + + market_label = market_match.group(1) if market_match else None + exchange = "KOSDAQ" if market_label == "코스닥" else "KOSPI" + + return { + "stock_code": stock_code, + "name": name, + "current_price": current_price, + "change": change, + "change_pct": change_pct, + "market_cap": market_cap, + "fifty_two_week_high": _parse_number(high_low_match.group(1) if high_low_match else None), + "fifty_two_week_low": _parse_number(high_low_match.group(2) if high_low_match else None), + "pe_ratio": _parse_number(trailing_match.group(1) if trailing_match else None), + "trailing_eps": _parse_number(trailing_match.group(2) if trailing_match else None), + "forward_pe": _parse_number(forward_match.group(1) if forward_match else None), + "forward_eps": _parse_number(forward_match.group(2) if forward_match else None), + "dividend_yield": _parse_number(dividend_match.group(1) if dividend_match else None), + "target_mean_price": _parse_number(target_match.group(3) if target_match else None), + "recommendation": _normalize_recommendation(target_match.group(2) if target_match else None), + "analyst_score": _parse_number(target_match.group(1) if target_match else None), + "shares_outstanding": _parse_int(shares_match.group(1) if shares_match else None), + "exchange": exchange, + "market_label": market_label, + "currency": "KRW", + "country": "KR", + "description": re.sub(r"\s+", " ", summary_match.group(1)).strip() if summary_match else None, + "source": "naver_finance", + } + + +async def get_naver_stock_snapshot(ticker_or_code: str, force_refresh: bool = False) -> dict[str, Any] | None: + stock_code = _stock_code_from_any(ticker_or_code) + if not stock_code: + return None + + now = time.monotonic() + async with _SNAPSHOT_LOCK: + cached = _SNAPSHOT_CACHE.get(stock_code) + if not force_refresh and cached and now - cached[0] < _NAVER_SNAPSHOT_TTL_SECONDS: + return cached[1] + + try: + async with httpx.AsyncClient(headers=_COMMON_HEADERS, timeout=15.0, follow_redirects=True) as client: + response = await client.get(_NAVER_MAIN_URL, params={"code": stock_code}) + response.raise_for_status() + html = _decode_html(response.content, response.encoding) + snapshot = _parse_naver_snapshot(html, stock_code) + listing = await get_korean_listing(stock_code) + if listing: + snapshot["ticker"] = listing["ticker"] + snapshot["name_ko"] = listing.get("name_ko") + snapshot["name_en"] = listing.get("name_en") + snapshot["name"] = listing.get("name_en") or snapshot.get("name") or listing.get("name") or listing.get("name_ko") + snapshot["exchange"] = listing.get("exchange", snapshot.get("exchange")) + snapshot["market"] = listing.get("market") + snapshot["corp_code"] = listing.get("corp_code") + else: + snapshot["ticker"] = f"{stock_code}.KS" + snapshot["market"] = "Korea Main Board" if snapshot.get("exchange") == "KOSPI" else "Korea Growth Board" + + async with _SNAPSHOT_LOCK: + _SNAPSHOT_CACHE[stock_code] = (time.monotonic(), snapshot) + return snapshot + except Exception as exc: + logger.warning("korean_market: Naver snapshot failed for %s: %s", stock_code, exc) + async with _SNAPSHOT_LOCK: + cached = _SNAPSHOT_CACHE.get(stock_code) + return cached[1] if cached else None diff --git a/atlas-terminal/server/services/korean_stock_universe.py b/atlas-terminal/server/services/korean_stock_universe.py new file mode 100644 index 0000000..b680abd --- /dev/null +++ b/atlas-terminal/server/services/korean_stock_universe.py @@ -0,0 +1,162 @@ +"""Local Korean stock universe loader and search helpers. + +This module provides a reusable KOSPI/KOSDAQ universe foundation that reads +from a local normalized artifact. The loader prefers a generated artifact when +present and falls back to a checked-in seed file, so the rest of the backend can +depend on a stable interface regardless of how the universe was built. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +import json +from functools import lru_cache +from pathlib import Path +import unicodedata + + +_DATA_DIR = Path(__file__).resolve().parents[1] / "data" +_ARTIFACT_CANDIDATES = ( + _DATA_DIR / "krx_universe.generated.json", + _DATA_DIR / "krx_universe.seed.json", +) + + +@dataclass(frozen=True) +class KoreanStockRecord: + ticker: str + market: str + name_ko: str + name_en: str | None + aliases: tuple[str, ...] + yfinance_ticker: str + + def to_dict(self) -> dict[str, object]: + data = asdict(self) + data["aliases"] = list(self.aliases) + return data + + +def _normalize_text(value: str) -> str: + return ( + unicodedata.normalize("NFKC", value or "") + .lower() + .replace("&", " and ") + .replace("_", " ") + .replace("-", " ") + .replace(".", " ") + ) + + +def _collapse_text(value: str) -> str: + return "".join(_normalize_text(value).split()) + + +def _artifact_path() -> Path: + for candidate in _ARTIFACT_CANDIDATES: + if candidate.exists(): + return candidate + raise FileNotFoundError("No Korean stock universe artifact found.") + + +def _normalize_row(row: dict[str, object]) -> KoreanStockRecord: + ticker = str(row.get("ticker", "")).strip() + market = str(row.get("market", "")).strip().upper() + name_ko = str(row.get("name_ko", "")).strip() + name_en_raw = str(row.get("name_en", "") or "").strip() + name_en = name_en_raw or None + aliases = tuple(str(alias).strip() for alias in (row.get("aliases", []) or []) if str(alias).strip()) + yfinance_ticker = str(row.get("yfinance_ticker", "")).strip().upper() + + if len(ticker) != 6 or not ticker.isdigit(): + raise ValueError(f"Invalid KRX ticker code: {ticker!r}") + if market not in {"KOSPI", "KOSDAQ"}: + raise ValueError(f"Invalid Korean market: {market!r}") + if not name_ko: + raise ValueError(f"Missing Korean company name for ticker {ticker}") + if not yfinance_ticker: + raise ValueError(f"Missing yfinance ticker for ticker {ticker}") + + return KoreanStockRecord( + ticker=ticker, + market=market, + name_ko=name_ko, + name_en=name_en, + aliases=aliases, + yfinance_ticker=yfinance_ticker, + ) + + +@lru_cache(maxsize=1) +def load_korean_stock_universe() -> tuple[KoreanStockRecord, ...]: + path = _artifact_path() + rows = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(rows, list): + raise ValueError("Korean stock universe artifact must be a JSON list.") + return tuple(_normalize_row(row) for row in rows if isinstance(row, dict)) + + +def _search_terms(record: KoreanStockRecord) -> tuple[str, ...]: + parts = [record.ticker, record.yfinance_ticker, record.name_ko] + if record.name_en: + parts.append(record.name_en) + parts.extend(record.aliases) + return tuple(parts) + + +def get_korean_stock_record(identifier: str) -> KoreanStockRecord | None: + query = (identifier or "").strip().upper() + if not query: + return None + + for record in load_korean_stock_universe(): + if query in {record.ticker, record.yfinance_ticker.upper()}: + return record + return None + + +def search_korean_stock_universe( + query: str = "", + *, + market: str | None = None, + limit: int = 20, +) -> list[KoreanStockRecord]: + normalized_market = (market or "").strip().upper() or None + rows = [ + record + for record in load_korean_stock_universe() + if normalized_market is None or record.market == normalized_market + ] + if not query.strip(): + return rows[: max(1, limit)] + + norm_query = _normalize_text(query) + collapsed_query = _collapse_text(query) + + def score(record: KoreanStockRecord) -> int: + best = 0 + for term in _search_terms(record): + normalized = _normalize_text(term) + collapsed = _collapse_text(term) + if normalized == norm_query or collapsed == collapsed_query: + best = max(best, 1000) + elif normalized.startswith(norm_query) or collapsed.startswith(collapsed_query): + best = max(best, 700) + elif norm_query in normalized or collapsed_query in collapsed: + best = max(best, 400) + return best + + ranked = [(score(record), record) for record in rows] + ranked = [item for item in ranked if item[0] > 0] + ranked.sort(key=lambda item: (-item[0], item[1].market, item[1].ticker)) + return [record for _, record in ranked[: max(1, limit)]] + + +def korean_stock_universe_metadata() -> dict[str, object]: + path = _artifact_path() + rows = load_korean_stock_universe() + return { + "source_path": str(path), + "count": len(rows), + "markets": sorted({record.market for record in rows}), + } diff --git a/atlas-terminal/tests/test_ft_epaper_service.py b/atlas-terminal/tests/test_ft_epaper_service.py new file mode 100644 index 0000000..f1378ae --- /dev/null +++ b/atlas-terminal/tests/test_ft_epaper_service.py @@ -0,0 +1,140 @@ +import asyncio +from datetime import date, datetime, timedelta, timezone + +from fastapi.testclient import TestClient + +from server.main import app +from server.routers import daily_news +from server.services import ft_epaper_service + + +def test_fetch_headlines_filters_to_requested_date(monkeypatch) -> None: + target = date(2026, 4, 23) + store: dict[str, object] = {} + + async def fake_cache_get(key: str): + return store.get(key) + + async def fake_cache_set(key: str, value, ttl: int = 86400): + store[key] = value + + async def fake_feed_entries(client, section: str, url: str): + return [ + { + "url": f"https://www.ft.com/content/{section.lower()}-today", + "title_en": f"{section} Today", + "lede_en": None, + "section": section, + "published_at": datetime(2026, 4, 23, 8, 0, tzinfo=timezone.utc).isoformat(), + }, + { + "url": f"https://www.ft.com/content/{section.lower()}-old", + "title_en": f"{section} Old", + "lede_en": None, + "section": section, + "published_at": datetime(2026, 4, 22, 8, 0, tzinfo=timezone.utc).isoformat(), + }, + ] + + async def fake_meta(client, url: str): + return {"lede_en": f"Meta for {url}", "image": "https://images.ft.com/example.jpg"} + + monkeypatch.setattr(ft_epaper_service.repo, "cache_get", fake_cache_get) + monkeypatch.setattr(ft_epaper_service.repo, "cache_set", fake_cache_set) + monkeypatch.setattr(ft_epaper_service, "_fetch_feed_entries", fake_feed_entries) + monkeypatch.setattr(ft_epaper_service, "_fetch_article_meta", fake_meta) + + items = asyncio.run(ft_epaper_service.fetch_headlines(target, limit=10)) + + assert items + assert all(datetime.fromisoformat(item["published_at"]).date() == target for item in items) + assert all(item["image"] == "https://images.ft.com/example.jpg" for item in items) + + +def test_translate_headlines_uses_cache_on_second_call(monkeypatch) -> None: + store: dict[str, object] = {} + gemini_calls = {"count": 0} + item = { + "url": "https://www.ft.com/content/test-article", + "title_en": "Fed holds rates steady", + "title_ko": None, + "lede_en": "Bond yields eased after the decision.", + "lede_ko": None, + "section": "Markets", + "published_at": datetime(2026, 4, 23, 7, 0, tzinfo=timezone.utc).isoformat(), + "image": None, + } + + async def fake_cache_get(key: str): + return store.get(key) + + async def fake_cache_set(key: str, value, ttl: int = 86400): + store[key] = value + + async def fake_generate_text( + prompt: str, + temperature: float = 0.3, + max_tokens: int = 1200, + api_key: str | None = None, + ) -> str: + gemini_calls["count"] += 1 + return ( + '[{"url":"https://www.ft.com/content/test-article",' + '"title_ko":"연준, 금리 동결","lede_ko":"결정 이후 채권 수익률이 완화됐다."}]' + ) + + monkeypatch.setattr(ft_epaper_service.repo, "cache_get", fake_cache_get) + monkeypatch.setattr(ft_epaper_service.repo, "cache_set", fake_cache_set) + monkeypatch.setattr(ft_epaper_service, "generate_text", fake_generate_text) + + first = asyncio.run(ft_epaper_service.translate_headlines([item])) + second = asyncio.run(ft_epaper_service.translate_headlines([item])) + + assert gemini_calls["count"] == 1 + assert first[0]["title_ko"] == "연준, 금리 동결" + assert second[0]["title_ko"] == "연준, 금리 동결" + + +def test_daily_news_rejects_dates_older_than_seven_days() -> None: + too_old = (date.today() - timedelta(days=8)).isoformat() + + with TestClient(app) as client: + response = client.get(f"/api/daily-news/{too_old}") + + assert response.status_code == 400 + assert "most recent 7 days" in response.json()["detail"] + + +def test_daily_news_forwards_browser_gemini_key(monkeypatch) -> None: + captured: dict[str, object] = {} + + async def fake_fetch_headlines(target_date, limit: int = 30): + return [ + { + "url": "https://www.ft.com/content/test-article", + "title_en": "Fed holds rates steady", + "title_ko": None, + "lede_en": "Bond yields eased after the decision.", + "lede_ko": None, + "section": "Markets", + "published_at": datetime(2026, 4, 24, 7, 0, tzinfo=timezone.utc).isoformat(), + "image": None, + } + ] + + async def fake_translate_headlines(items, api_key=None): + captured["api_key"] = api_key + return [{**items[0], "title_ko": "연준, 금리 동결", "lede_ko": "결정 이후 채권 수익률이 완화됐다."}] + + monkeypatch.setattr(daily_news, "fetch_headlines", fake_fetch_headlines) + monkeypatch.setattr(daily_news, "translate_headlines", fake_translate_headlines) + + with TestClient(app) as client: + response = client.get( + "/api/daily-news/2026-04-24", + headers={"x-gemini-api-key": "test-browser-key"}, + ) + + assert response.status_code == 200 + assert captured["api_key"] == "test-browser-key" + assert response.json()[0]["title_ko"] == "연준, 금리 동결" diff --git a/atlas-terminal/tests/test_korean_stock_universe.py b/atlas-terminal/tests/test_korean_stock_universe.py new file mode 100644 index 0000000..3289112 --- /dev/null +++ b/atlas-terminal/tests/test_korean_stock_universe.py @@ -0,0 +1,67 @@ +from fastapi.testclient import TestClient + +from server.main import app +from server.services.korean_stock_universe import ( + get_korean_stock_record, + korean_stock_universe_metadata, + load_korean_stock_universe, + search_korean_stock_universe, +) + + +def test_load_korean_stock_universe_has_seed_rows() -> None: + rows = load_korean_stock_universe() + + assert rows + assert any(row.ticker == "005930" and row.market == "KOSPI" for row in rows) + assert any(row.ticker == "247540" and row.market == "KOSDAQ" for row in rows) + + +def test_search_by_korean_name_and_alias() -> None: + samsung = search_korean_stock_universe("삼성전자", limit=5) + hynix = search_korean_stock_universe("sk hynix", limit=5) + + assert samsung[0].ticker == "005930" + assert hynix[0].ticker == "000660" + + +def test_lookup_by_bare_and_yfinance_ticker() -> None: + bare = get_korean_stock_record("005930") + qualified = get_korean_stock_record("000660.KS") + + assert bare is not None + assert bare.name_ko == "삼성전자" + assert qualified is not None + assert qualified.name_ko == "SK하이닉스" + + +def test_metadata_reports_local_artifact() -> None: + metadata = korean_stock_universe_metadata() + + assert metadata["count"] >= 10 + assert "KOSPI" in metadata["markets"] + assert "KOSDAQ" in metadata["markets"] + assert str(metadata["source_path"]).endswith(".json") + + +def test_search_endpoint_returns_normalized_fields() -> None: + with TestClient(app) as client: + response = client.get("/api/market/korean-universe/search", params={"q": "하이닉스", "limit": 3}) + + assert response.status_code == 200 + body = response.json() + assert body["count"] >= 1 + item = body["items"][0] + assert set(item) == {"ticker", "market", "name_ko", "name_en", "aliases", "yfinance_ticker"} + assert item["ticker"] == "000660" + assert item["yfinance_ticker"] == "000660.KS" + + +def test_lookup_endpoint_supports_yfinance_ticker() -> None: + with TestClient(app) as client: + response = client.get("/api/market/korean-universe/247540.KQ") + + assert response.status_code == 200 + body = response.json() + assert body["item"]["ticker"] == "247540" + assert body["item"]["market"] == "KOSDAQ" diff --git a/atlas-terminal/tests/test_smoke.py b/atlas-terminal/tests/test_smoke.py index 0c5c41c..632a0c2 100644 --- a/atlas-terminal/tests/test_smoke.py +++ b/atlas-terminal/tests/test_smoke.py @@ -16,6 +16,7 @@ EXPECTED_PREFIXES = [ "/api/crypto", "/api/copilot", "/api/credentials", + "/api/daily-news", "/api/dart", "/api/earnings", "/api/edgar",