mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-17 04:18:08 +00:00
- /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
242 lines
9.9 KiB
Python
242 lines
9.9 KiB
Python
"""ETF and equity-like overview helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from typing import Any
|
|
|
|
|
|
def _safe_num(v: Any) -> float | None:
|
|
try:
|
|
f = float(v)
|
|
if math.isnan(f) or math.isinf(f):
|
|
return None
|
|
return f
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _compute_sharpe(returns) -> float | None:
|
|
if returns is None or len(returns) < 2:
|
|
return None
|
|
std = returns.std()
|
|
if not std:
|
|
return None
|
|
return round(float((returns.mean() / std) * (252**0.5)), 2)
|
|
|
|
|
|
def _compute_sortino(returns) -> float | None:
|
|
if returns is None or len(returns) < 2:
|
|
return None
|
|
downside = returns[returns < 0]
|
|
if downside is None or len(downside) < 2:
|
|
return None
|
|
std = downside.std()
|
|
if not std:
|
|
return None
|
|
return round(float((returns.mean() / std) * (252**0.5)), 2)
|
|
|
|
|
|
def _max_drawdown(returns) -> float | None:
|
|
if returns is None or len(returns) < 2:
|
|
return None
|
|
curve = (1 + returns).cumprod()
|
|
dd = (curve / curve.cummax()) - 1
|
|
return round(float(dd.min()) * 100, 2)
|
|
|
|
|
|
async def get_benchmark_comparison(ticker: str, benchmark: str = "SPY", period: str = "1y") -> dict:
|
|
import yfinance as yf
|
|
|
|
data = yf.download([ticker.upper(), benchmark.upper()], period=period, auto_adjust=True, progress=False)
|
|
if data is None or data.empty:
|
|
return {}
|
|
close = data["Close"] if "Close" in data else data
|
|
if close is None or close.empty:
|
|
return {}
|
|
t_col = ticker.upper()
|
|
b_col = benchmark.upper()
|
|
if t_col not in close.columns or b_col not in close.columns:
|
|
return {}
|
|
close = close[[t_col, b_col]].dropna()
|
|
if close.empty:
|
|
return {}
|
|
normalized = close / close.iloc[0] * 100
|
|
return {
|
|
"dates": normalized.index.strftime("%Y-%m-%d").tolist(),
|
|
"ticker_values": [float(x) for x in normalized[t_col].tolist()],
|
|
"benchmark_values": [float(x) for x in normalized[b_col].tolist()],
|
|
"benchmark": benchmark.upper(),
|
|
}
|
|
|
|
|
|
async def get_etf_holdings(ticker: str, top_n: int = 10) -> list[dict]:
|
|
import yfinance as yf
|
|
|
|
t = yf.Ticker(ticker.upper())
|
|
out = []
|
|
try:
|
|
holdings = getattr(t, "fund_top_holdings", None)
|
|
if holdings is not None and not holdings.empty:
|
|
for _, row in holdings.head(top_n).iterrows():
|
|
out.append(
|
|
{
|
|
"symbol": row.get("symbol") or row.get("holdingName") or "",
|
|
"name": row.get("holdingName") or row.get("symbol") or "",
|
|
"weight_pct": _safe_num(row.get("holdingPercent")),
|
|
}
|
|
)
|
|
except Exception:
|
|
pass
|
|
return out
|
|
|
|
|
|
async def get_etf_overview(ticker: str) -> dict:
|
|
import yfinance as yf
|
|
|
|
t = yf.Ticker(ticker.upper())
|
|
info = t.info or {}
|
|
hist = t.history(period="5y", auto_adjust=True)
|
|
|
|
def period_return(days: int) -> float | None:
|
|
if hist is None or hist.empty or len(hist) <= days:
|
|
return None
|
|
cur = _safe_num(hist["Close"].iloc[-1])
|
|
prev = _safe_num(hist["Close"].iloc[-days])
|
|
if cur is None or prev is None or prev == 0:
|
|
return None
|
|
return round((cur / prev - 1) * 100, 2)
|
|
|
|
ytd_days = 0
|
|
if hist is not None and not hist.empty:
|
|
ytd_days = int((hist.index.year == hist.index[-1].year).sum())
|
|
returns = hist["Close"].pct_change().dropna() if hist is not None and not hist.empty else None
|
|
|
|
return {
|
|
"name": info.get("longName") or info.get("shortName", ticker.upper()),
|
|
"category": info.get("category") or info.get("fundFamily") or "N/A",
|
|
"aum": _safe_num(info.get("totalAssets")),
|
|
"expense_ratio": _safe_num(info.get("annualReportExpenseRatio")),
|
|
"nav": _safe_num(info.get("navPrice")),
|
|
"inception": info.get("fundInceptionDate"),
|
|
"price": _safe_num(info.get("currentPrice") or info.get("regularMarketPrice")),
|
|
"high_52w": _safe_num(info.get("fiftyTwoWeekHigh")),
|
|
"low_52w": _safe_num(info.get("fiftyTwoWeekLow")),
|
|
"returns": {
|
|
"1m": period_return(21),
|
|
"3m": period_return(63),
|
|
"6m": period_return(126),
|
|
"ytd": period_return(ytd_days) if ytd_days else None,
|
|
"1y": period_return(252),
|
|
"3y": period_return(756),
|
|
"5y": period_return(1260),
|
|
},
|
|
"holdings": await get_etf_holdings(ticker, top_n=10),
|
|
"risk": {
|
|
"sharpe": _compute_sharpe(returns),
|
|
"sortino": _compute_sortino(returns),
|
|
"max_drawdown": _max_drawdown(returns),
|
|
"volatility": round(float(returns.std()) * (252**0.5) * 100, 2) if returns is not None and len(returns) > 1 else None,
|
|
},
|
|
"benchmark_comparison": await get_benchmark_comparison(ticker, "SPY", "1y"),
|
|
}
|
|
|
|
|
|
async def get_equity_overview(ticker: str) -> dict:
|
|
import yfinance as yf
|
|
|
|
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
|
|
# (common for some Asian tickers like 005930.KS where info.fiftyTwoWeekLow == 0)
|
|
hi52 = _safe_num(info.get("fiftyTwoWeekHigh"))
|
|
lo52 = _safe_num(info.get("fiftyTwoWeekLow"))
|
|
if not hi52 or not lo52 or lo52 == 0:
|
|
try:
|
|
hist = t.history(period="1y")
|
|
if hist is not None and len(hist) > 0:
|
|
h_max = float(hist["High"].max())
|
|
l_min = float(hist["Low"].min())
|
|
if not hi52:
|
|
hi52 = h_max
|
|
if not lo52 or lo52 == 0:
|
|
lo52 = l_min
|
|
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": 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": market_cap,
|
|
"pe_ratio": _safe_num(info.get("trailingPE")) or _safe_num(info.get("forwardPE")),
|
|
"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": 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(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")),
|
|
"return_on_assets": _safe_num(info.get("returnOnAssets")),
|
|
"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(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",
|
|
}
|