mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-13 18:38:06 +00:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"
|
||||
@@ -16,6 +16,7 @@ EXPECTED_PREFIXES = [
|
||||
"/api/crypto",
|
||||
"/api/copilot",
|
||||
"/api/credentials",
|
||||
"/api/daily-news",
|
||||
"/api/dart",
|
||||
"/api/earnings",
|
||||
"/api/edgar",
|
||||
|
||||
Reference in New Issue
Block a user