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
This commit is contained in:
shawnkim1997
2026-05-10 23:56:27 +01:00
parent fabbe27e06
commit ef429e93c4
23 changed files with 2582 additions and 75 deletions
@@ -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<string>;
bookmarkedDates: Set<string>;
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 (
<section className="atlas-card p-4">
<div className="mb-4 flex items-center justify-between">
<button type="button" onClick={() => shiftMonth(-1)} className="rounded-md border border-border p-2 text-text-secondary hover:bg-surface-sunken">
<ChevronLeft className="h-4 w-4" />
</button>
<div className="font-serif text-lg font-bold text-brand-navy">
{parseDateKey(visibleMonth).toLocaleDateString(undefined, { month: "long", year: "numeric" })}
</div>
<button type="button" onClick={() => shiftMonth(1)} className="rounded-md border border-border p-2 text-text-secondary hover:bg-surface-sunken">
<ChevronRight className="h-4 w-4" />
</button>
</div>
<div className="grid grid-cols-7 gap-1 text-center text-[11px] font-semibold uppercase tracking-[0.12em] text-text-muted">
{DAY_LABELS.map((label, index) => <div key={`${label}-${index}`}>{label}</div>)}
</div>
<div className="mt-2 grid grid-cols-7 gap-1">
{cells.map((cell, index) => {
if (!cell) {
return <div key={`blank-${index}`} className="h-12 rounded-md bg-surface-sunken/30" />;
}
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 (
<button
key={key}
type="button"
onClick={() => {
if (!supported) {
onUnsupportedDate("FT RSS currently covers only the most recent 7 days. Open FT.com directly for older archive dates.");
return;
}
onSelectDate(key);
}}
className={`flex h-12 flex-col items-center justify-center rounded-md border text-sm transition ${
selected
? "border-brand-gold bg-brand-navy text-white"
: supported
? "border-border bg-white text-text-primary hover:bg-surface-sunken"
: "border-border/50 bg-surface-sunken/50 text-text-muted"
}`}
>
<span className={`font-mono ${isToday && !selected ? "text-brand-blue" : ""}`}>{cell.getDate()}</span>
<span className="mt-1 flex items-center gap-1 text-[10px]">
{read && <span></span>}
{bookmarked && <span></span>}
</span>
</button>
);
})}
</div>
</section>
);
}
@@ -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 (
<article className="atlas-card overflow-hidden">
<div className="flex flex-col gap-4 p-5 lg:flex-row">
<div className="w-full shrink-0 lg:w-40">
{headline.image ? (
<Image
src={headline.image}
alt={headline.title_en}
width={160}
height={112}
className="h-28 w-full rounded-md object-cover"
/>
) : (
<div className="flex h-28 items-center justify-center rounded-md border border-border bg-surface-sunken px-4 text-center">
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-brand-navy">
{headline.section || "FT"}
</span>
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="mb-2 flex flex-wrap items-center gap-2 text-[11px] uppercase tracking-[0.12em]">
<span className="rounded border border-brand-gold/40 bg-brand-gold/10 px-2 py-1 text-brand-navy">
{headline.section || "Financial Times"}
</span>
<span className="font-mono text-text-muted">
{new Date(headline.published_at).toLocaleString()}
</span>
</div>
<h3 className="font-serif text-xl font-bold text-brand-navy">{displayTitle}</h3>
<p className="mt-1 text-sm text-text-muted">{headline.title_en}</p>
<p
className="mt-3 text-sm leading-6 text-text-secondary"
style={{ display: "-webkit-box", WebkitLineClamp: 3, WebkitBoxOrient: "vertical", overflow: "hidden" }}
>
{displayLede}
</p>
<div className="mt-4 flex flex-wrap gap-2">
<a
href={headline.url}
target="_blank"
rel="noopener noreferrer"
className="rounded-md bg-brand-navy px-3 py-2 text-sm font-semibold text-white transition hover:bg-brand-blue"
>
FT에서
</a>
<button
type="button"
onClick={onToggleRead}
className={`rounded-md border px-3 py-2 text-sm font-semibold transition ${
isRead
? "border-fin-positive/40 bg-fin-positive/10 text-fin-positive"
: "border-border bg-surface-sunken text-text-secondary hover:border-brand-blue/40 hover:text-brand-navy"
}`}
>
{isRead ? "읽음 ✓" : "읽음 표시"}
</button>
<button
type="button"
onClick={onToggleBookmark}
className={`rounded-md border px-3 py-2 text-sm font-semibold transition ${
isBookmarked
? "border-brand-gold/40 bg-brand-gold/10 text-brand-navy"
: "border-border bg-surface-sunken text-text-secondary hover:border-brand-gold/40 hover:text-brand-navy"
}`}
>
{isBookmarked ? "★ 북마크됨" : "☆ 북마크"}
</button>
</div>
</div>
</div>
</article>
);
}
@@ -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<string, unknown> | null;
}
interface FxRateMatrix {
rates?: Record<string, number>;
}
const ZERO_DECIMAL_CURRENCIES = new Set(["KRW", "JPY"]);
const CURRENCY_LOCALES: Record<string, string> = {
KRW: "ko-KR",
JPY: "ja-JP",
USD: "en-US",
EUR: "de-DE",
GBP: "en-GB",
DKK: "da-DK",
};
function getCurrencyCode(ticker: string, sector: Record<string, unknown> | 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<string, number>, 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<string, number>,
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<string, string> = {
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 (
<span className="tabular-nums">
<span className={symbolClass}>{parts.symbol}</span>
<span>{parts.value}</span>
</span>
);
}
export function EquityOverview({ ticker, sector, health }: EquityOverviewProps) {
const [peerData, setPeerData] = useState<PeerComparisonData | null>(null);
const [kpiData, setKpiData] = useState<KpiHistoryData | null>(null);
const fx = useApi<FxRateMatrix>("/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: <MoneyValue display={marketCap.primary} currency={currency} />, 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: <MoneyValue display={high52w.primary} currency={currency} />, detail: high52w.secondary || undefined },
{ label: "52W Low", value: <MoneyValue display={low52w.primary} currency={currency} />, detail: low52w.secondary || undefined },
];
return (
@@ -55,14 +173,19 @@ export function EquityOverview({ ticker, sector, health }: EquityOverviewProps)
<RedTeamCritique ticker={ticker} />
</div>
{sector?.current_price != null && (
<p className="text-3xl font-mono font-bold text-text-primary">${Number(sector.current_price).toFixed(2)}</p>
<div className="mb-6">
<p className="text-3xl font-mono font-bold text-text-primary">
<MoneyValue display={spot.primary} currency={currency} emphasis="hero" />
</p>
{spot.secondary && <p className="mt-1 text-sm font-mono text-text-muted">{spot.secondary}</p>}
</div>
)}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
{metrics.map((m) => (
<StatCard key={m.label} label={m.label} value={m.value} />
<StatCard key={m.label} label={m.label} value={m.value} detail={m.detail} />
))}
</div>
<ConsensusGauge sector={sector} />
<ConsensusGauge ticker={ticker} sector={sector} rates={fx.data?.rates} />
<KpiSection data={kpiData} />
<div className="grid grid-cols-1 gap-4 mb-4 lg:grid-cols-4">
<OverviewStat title="Altman Z-Score" value={health?.altman_z != null ? Number(health.altman_z).toFixed(2) : "—"} />
@@ -96,7 +219,16 @@ export function EquityOverview({ ticker, sector, health }: EquityOverviewProps)
);
}
function ConsensusGauge({ sector }: { sector: Record<string, unknown> | null }) {
function ConsensusGauge({
ticker,
sector,
rates,
}: {
ticker: string;
sector: Record<string, unknown> | null;
rates?: Record<string, number>;
}) {
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<string, unknown> | 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 (
<Card title="Analyst Consensus" className="mb-6">
@@ -125,7 +258,10 @@ function ConsensusGauge({ sector }: { sector: Record<string, unknown> | null })
<div className="flex items-baseline gap-4 mb-4">
<div>
<span className="text-text-muted text-xs block">Target</span>
<span className="text-2xl font-mono font-bold text-text-primary">${target.toFixed(2)}</span>
<span className="text-2xl font-mono font-bold text-text-primary">
<MoneyValue display={targetDisplay.primary} currency={currency} emphasis="card" />
</span>
{targetDisplay.secondary && <span className="mt-1 block text-xs font-mono text-text-muted">{targetDisplay.secondary}</span>}
</div>
<div>
<span className="text-text-muted text-xs block">Upside</span>
@@ -146,8 +282,8 @@ function ConsensusGauge({ sector }: { sector: Record<string, unknown> | null })
<div className="absolute -top-1 h-4 w-1 rounded-sm bg-brand-navy" style={{ left: `${currentPct}%` }} />
</div>
<div className="flex justify-between text-text-muted text-xs font-mono">
<span>${gaugeLow.toFixed(0)}</span>
<span>${gaugeHigh.toFixed(0)}</span>
<span><MoneyValue display={moneyDisplay(gaugeLow, currency, rates).primary} currency={currency} emphasis="label" /></span>
<span><MoneyValue display={moneyDisplay(gaugeHigh, currency, rates).primary} currency={currency} emphasis="label" /></span>
</div>
</Card>
);
@@ -43,6 +43,31 @@ const METRIC_LABELS: Record<MetricKey, string> = {
};
const LOWER_IS_BETTER = new Set<MetricKey>(["pe", "ev_ebitda"]);
const ZERO_DECIMAL_CURRENCIES = new Set(["KRW", "JPY"]);
const CURRENCY_LOCALES: Record<string, string> = {
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({
</td>
<td className={`py-3 pr-3 ${isCurrent ? "text-white/80" : "text-text-secondary"}`}>{peer.name}</td>
<td className={`py-3 pr-3 font-mono text-right tabular-nums ${isCurrent ? "text-white" : "text-text-primary"}`}>
{formatValue(peer.market_cap, "marketCap")}
{formatMarketCap(peer.market_cap, peer.ticker)}
</td>
{metrics.map((metric) => {
const value = peer[metric];
@@ -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 && <LoaderCircle className="h-4 w-4 animate-spin text-brand-blue" />}
</div>
{showSuggestions && (
<div id="ticker-suggestions" role="listbox" className="mt-2 overflow-hidden rounded-md border border-border bg-surface-raised shadow-card">
@@ -130,19 +139,50 @@ export function Sidebar() {
>
<div className="flex items-center justify-between gap-2">
<span className={`font-mono text-sm font-bold ${active ? "text-brand-gold" : "text-brand-navy"}`}>{suggestion.ticker}</span>
<span className={`rounded border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.08em] ${
active ? "border-white/20 text-white/70" : "border-border text-text-muted"
}`}>
{suggestion.exchange}
</span>
<div className="flex items-center gap-1.5">
{suggestion.country && (
<span className={`rounded border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.08em] ${
active ? "border-white/20 text-white/70" : "border-border text-text-muted"
}`}>
{suggestion.country}
</span>
)}
<span className={`rounded border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.08em] ${
active ? "border-white/20 text-white/70" : "border-border text-text-muted"
}`}>
{suggestion.exchange}
</span>
</div>
</div>
<div className={`mt-0.5 truncate text-xs ${active ? "text-white/80" : "text-text-secondary"}`}>{suggestion.name}</div>
{(suggestion.nameKo || suggestion.market || suggestion.currency) && (
<div className={`mt-1 flex flex-wrap items-center gap-1.5 text-[11px] ${active ? "text-white/70" : "text-text-muted"}`}>
{suggestion.nameKo && <span className="truncate">{suggestion.nameKo}</span>}
{suggestion.market && (
<span className={`rounded px-1.5 py-0.5 ${
active ? "bg-white/10 text-white/75" : "bg-surface-sunken text-text-secondary"
}`}>
{suggestion.market}
</span>
)}
{suggestion.currency && (
<span className={`font-mono ${
active ? "text-brand-gold" : "text-brand-blue"
}`}>
{suggestion.currency}
</span>
)}
</div>
)}
</button>
);
})}
<div className="border-t border-border bg-surface-sunken px-3 py-1.5 text-[10px] uppercase tracking-[0.08em] text-text-muted">
{source === "remote" ? "Live search results" : "Local desk search fallback"}
</div>
</div>
)}
{input.trim().length > 0 && suggestions.length === 0 && (
{input.trim().length > 0 && suggestions.length === 0 && !loading && (
<div className="mt-2 rounded-md border border-border bg-surface-sunken px-3 py-2 text-xs text-text-muted">
No match yet. Press Enter to use <span className="font-mono text-brand-navy">{input.trim().toUpperCase()}</span>.
</div>
@@ -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<FTHeadline[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(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 (
<div className="atlas-page">
<div className="mb-5 flex flex-wrap items-start justify-between gap-3">
<div>
<SectionHeading level={1}>Daily News</SectionHeading>
<p className="mt-1 text-sm text-text-secondary">Financial Times headlines with Korean summary and one-click FT open-in-new-tab workflow.</p>
</div>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={() => setSelectedDate(todayKey())} className="rounded-md border border-border bg-white px-3 py-2 text-sm font-semibold text-brand-navy hover:bg-surface-sunken">
Today
</button>
<button type="button" onClick={() => setSelectedDate(addDays(todayKey(), -1))} className="rounded-md border border-border bg-white px-3 py-2 text-sm font-semibold text-brand-navy hover:bg-surface-sunken">
Yesterday
</button>
<a href={todayLink} target="_blank" rel="noopener noreferrer" className="rounded-md bg-brand-navy px-3 py-2 text-sm font-semibold text-white hover:bg-brand-blue">
FT ePaper
</a>
</div>
</div>
<ErrorBanner variant="info" message={notice} className="mb-4" />
<ErrorBanner
variant="info"
message={hasGeminiKey ? null : "Set your Gemini API key in Settings to see Korean headline and lede translation. Without it, FT originals still load in English."}
className="mb-4"
/>
<ErrorBanner variant="error" message={error} className="mb-4" />
<div className="grid gap-4 xl:grid-cols-[320px_minmax(0,1fr)]">
<DailyNewsCalendar
selectedDate={selectedDate}
readDates={readDates}
bookmarkedDates={bookmarkedDates}
onSelectDate={setSelectedDate}
onUnsupportedDate={setNotice}
/>
<section className="min-w-0">
<div className="atlas-card mb-4 p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 className="font-serif text-2xl font-bold text-brand-navy">{selectedLabel}</h2>
<p className="mt-1 text-sm text-text-muted">{headlines.length} FT headlines</p>
</div>
</div>
</div>
{loading ? (
<LoadingPulse label="Loading FT headlines..." />
) : headlines.length === 0 ? (
<div className="atlas-card p-6 text-sm text-text-muted">
No FT headlines were found for this date. Weekend or RSS coverage gaps can produce empty days.
</div>
) : (
<div className="space-y-4">
{headlines.map((headline) => {
const articleDate = headline.published_at.slice(0, 10);
return (
<HeadlineCard
key={headline.url}
headline={headline}
isRead={isRead(headline.url)}
isBookmarked={isBookmarked(headline.url)}
onToggleRead={() => toggleRead(headline.url, articleDate)}
onToggleBookmark={() => toggleBookmark(headline, articleDate)}
/>
);
})}
</div>
)}
</section>
</div>
</div>
);
}
@@ -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;
}
@@ -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",
});
}
@@ -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));
@@ -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<T>(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<string[]>([]);
const [readDates, setReadDates] = useState<string[]>([]);
const sync = useCallback(() => {
setReadUrls(readJson<string[]>(READ_URLS_KEY, []));
setReadDates(readJson<string[]>(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<string[]>(READ_URLS_KEY, []));
const nextDates = new Set(readJson<string[]>(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<FTBookmark[]>([]);
const [bookmarkedDates, setBookmarkedDates] = useState<string[]>([]);
const sync = useCallback(() => {
setBookmarks(readJson<FTBookmark[]>(BOOKMARKS_KEY, []));
setBookmarkedDates(readJson<string[]>(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<FTBookmark[]>(BOOKMARKS_KEY, []);
const nextDates = new Set(readJson<string[]>(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,
};
}
@@ -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<string, Promise<TickerSuggestion[]>>();
let remoteSearchAvailable: boolean | null = null;
type RemoteTickerSuggestion = Partial<TickerSuggestion> & {
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<TickerSuggestion[]> {
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<string, TickerSuggestion>();
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<TickerSuggestion[]>([]);
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;
}
+32 -1
View File
@@ -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")
+14 -1
View File
@@ -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")
@@ -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]
+67 -35
View File
@@ -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)
+20
View File
@@ -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,
}
+56 -12
View File
@@ -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",
}
@@ -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
@@ -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
@@ -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}),
}
@@ -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"] == "연준, 금리 동결"
@@ -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"
+1
View File
@@ -16,6 +16,7 @@ EXPECTED_PREFIXES = [
"/api/crypto",
"/api/copilot",
"/api/credentials",
"/api/daily-news",
"/api/dart",
"/api/earnings",
"/api/edgar",